A native iOS portfolio app, backed by a Rust API server, built in a Bazel monorepo.
The light-sensitive layer where an image takes permanent form.
![]() |
![]() |
- What this is
- Repository structure
- How to build and run
- How to run tests
- Assumptions and limitations
- Architecture
- Highlights
- Stack
- API
- Performance
- Documentation
The pitch. A network for creators. Each person gets a profile card and a portfolio of what they're working on; everyone else gets to discover them. Swipe through cards to see what people in your industry are building, tap into a profile to read the detail, and leave a note or open a thread to reach out. Think Tinder's gesture language with a creator-portfolio data model underneath — concise on the surface, deep when you want it.
A native iOS app (SwiftUI, iOS 26) talking over local HTTP/JSON to a Rust API server (axum, SQLite WAL), with a shared Rust types crate that defines the wire contract once. Built in a single Bazel monorepo alongside Cargo + Xcode for local iteration.
This submission seeds the system as a single-user portfolio (real CV content for Richard Lao) — but the data model and API are designed to extend to many creators. Submitted as a 24-hour take-home for Lapse; the goal is a working foundation that explains itself, not feature completeness.
End-to-end working flow:
- Polaroid TLDR card — swipe-or-tap entry point on the home tab.
- Portfolio detail — bio, experience, skills, projects, FAQs, "leave a note", AMA inbox.
- Real backend — every screen makes an HTTP call. Counters increment atomically. Cache invalidates on writes.
emulsion/
├── apps/ios/ SwiftUI app · MVVM · APIClientProtocol
│ ├── Sources/ Views, ViewModels, APIClient, Models, Theme
│ ├── Tests/ XCTest · 18 tests · MockAPIClient
│ └── PortfolioApp.xcodeproj Hand-rolled pbxproj (no SPM)
├── services/portfolio-api/ Rust axum backend · port 8080
│ ├── src/handlers/ extract → repo → map error → Json
│ ├── src/repositories/ SQL queries, atomic counter updates
│ ├── src/routes/tests.rs HTTP integration tests via tower::ServiceExt
│ ├── migrations/ sqlx migrations (schema · counters · FK indexes)
│ └── BUILD bazel rust_binary + rust_test
├── shared/emulsion-types/ UniFFI Rust crate · canonical wire types
├── tools/seed/ Populates SQLite from embedded CV JSON
├── docs/
│ ├── system-design.md Architecture, cache, latency, shared layer
│ ├── retrospective.md Decisions, tradeoffs, post-script
│ ├── test-plan.md Coverage by tier
│ └── screenshots/ README hero images
├── AGENTS.md Conventions for AI coding agents
├── CLAUDE.md Build/test commands and conventions
├── MODULE.bazel Bzlmod deps — rules_rust, rules_apple, rules_swift
├── Cargo.toml Workspace root + release profile (LTO, strip)
├── run.sh macOS one-shot: prereqs → seed → build → run
└── run.bat Windows backend-only equivalent
./run.shThis script: checks prerequisites (Rust, Xcode), seeds dev.db if missing, builds the backend + iOS app, and starts the server on localhost:8080. Then:
open apps/ios/PortfolioApp.xcodeproj
# ⌘R to run on iPhone 17 Pro Simulatorbazel build //services/portfolio-api:server # Rust binary
bazel build //apps/ios:app # iOS .ipa
bazel build //shared/emulsion-types:emulsion_types # Shared types crateBazel and Cargo coexist intentionally — Bazel is the canonical build, Cargo is for fast inner-loop iteration.
cargo run -p seed # creates dev.db, applies migrations, seeds CV
cargo run -p portfolio-api # starts on http://localhost:8080
open apps/ios/PortfolioApp.xcodeprojrun.bat
iOS requires macOS + Xcode.
| Suite | Count | Command |
|---|---|---|
| Backend repo + cache | 19 | cargo test -p portfolio-api |
| Backend DB pragma | 1 | (in same suite — asserts init_pool_with_url applies pragmas) |
| Backend HTTP integration | 7 | (in same suite — tower::ServiceExt::oneshot against the live router) |
| Backend FTS5 + theatre | 3 | (in same suite — porter stemming, theatre flag transition, AMA non-theatre) |
| Shared types | 4 | cargo test -p emulsion-types |
| iOS models / APIClient / ViewModels | 12 / 2 / 23 | xcodebuild test with MockAPIClient: APIClientProtocol |
Run everything:
cargo test --workspace # 34 Rust tests (30 backend + 4 shared types)
bazel test //... # 2 Bazel test targets aggregating the Rust suites
xcodebuild test \
-project apps/ios/PortfolioApp.xcodeproj \
-scheme PortfolioApp \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' # 37 iOS testsFull plan, including what isn't tested and why: docs/test-plan.md.
Assumptions baked in:
- Single user, single server, localhost only. No multi-tenancy, no auth beyond an
X-Owner-Tokenheader that's checked for presence (not value) on the notes listing. - Dataset is small and fixed (1 portfolio, 3 projects, 6 Q&As, a handful of conversations). All endpoints return full result sets — no pagination.
- Inbox conversations start as seeded "theatre" data (
is_theatre = 1). Sending a message or creating an AMA conversation flips the flag tois_theatre = 0— the API returns the real per-conversation theatre state. - macOS + Xcode is the dev environment. iOS app requires the Simulator; Windows users can still run the backend.
Known limitations:
- In-process cache only.
DashMapis per-process; no Redis / distributed cache. - iOS shared-types migration not finished. Backend uses
emulsion_types::PortfolioResponse. iOS still uses its own Codable mirrors. The xcframework exists; the pbxproj entry is the missing step. - Bazel iOS test target. Hand-rolled pbxproj makes
ios_unit_testpainful. iOS tests run viaxcodebuild testonly;bazel test //...covers the Rust suites. tools/seeduses compile-time path macros (sqlx::migrate!,include_str!) that don't resolve in Bazel's sandbox; taggedmanualand run via Cargo only.
What I'd change with more time is documented in docs/retrospective.md.
flowchart LR
subgraph iOS["iOS Client (SwiftUI · iOS 26)"]
VM["@Observable ViewModels"] --> API["APIClientProtocol"]
end
subgraph Backend["Rust Backend (axum 0.7.9)"]
Router["Router + TraceLayer"] --> Handlers
Handlers --> Repos["Repositories"]
Handlers --> Cache["DashMap cache"]
Repos --> SQLite[("SQLite WAL")]
end
Shared["shared/emulsion-types<br/>(canonical wire types)"]
API <-->|"HTTP/JSON · localhost:8080"| Router
Backend -. "use emulsion_types::*" .-> Shared
iOS -. "Codable mirrors" .-> Shared
Read path. PortfolioViewModel.load() → URLSession → GET /v1/portfolios/1 → cache check → tokio::join! over portfolio + experiences + skills queries → typed PortfolioResponse → SwiftUI re-render.
Write path (project view). POST /v1/projects/:id/view → atomic UPDATE … SET col = col + 1 → cache.invalidate_prefix("projects:"). GET /v1/projects/:id is pure and cacheable; the side-effecting view increment lives on its own POST.
See docs/system-design.md for the full design.
- End-to-end working system. Every screen hits a real backend.
- Shared platform layer is wired, not decorative. Backend
get_portfolioreturnsJson<emulsion_types::PortfolioResponse>.From<RowType> for emulsion_types::CanonicalTypeimpls make schema drift a compile error. - Latency-conscious backend. WAL-mode SQLite tuned with
synchronous = NORMAL,busy_timeout = 5s,foreign_keys = ON, 16 MB cache, B-tree indexes on every FK column. - Cache-aside reads.
DashMaplock-free in-process cache with prefix invalidation. Keys live in a typedcache::keysmodule. - Bazel builds both sides. Backend binary, iOS .ipa, and shared-types library all produced by Bazel. UniFFI scaffolding is feature-gated so the shared crate is sandbox-buildable.
- FTS5 full-text search. Q&A matching uses SQLite FTS5 with porter stemming — "builds" matches "Building", "works" matches "working". BM25 ranking returns the best hit.
- Tests that go through the router.
tower::ServiceExt::oneshotexercises real handler + extractor + JSON wiring. All 6 iOS ViewModels mocked throughAPIClientProtocol. Shared types have a wire-format regression guard. - Agent-ready.
AGENTS.mddocuments conventions, file layout, and patterns for AI coding agents.
| Layer | Tech | Notes |
|---|---|---|
| iOS | SwiftUI · iOS 26 · MVVM with @Observable |
Zero third-party deps. URLSession networking. EmulsionTheme enum for visual constants. |
| Backend | Rust 1.95 · axum 0.7.9 · sqlx 0.8 | Single-binary tokio server. SQLite WAL. tower-http TraceLayer for per-request logs. |
| Shared | UniFFI 0.28 · feature-gated | Wire types defined once in Rust. UDL schema → Swift xcframework via generate-bindings.sh. |
| Build | Bazel 9.1.0 (Bzlmod) · Cargo workspace · Xcode | rules_rust 0.70, rules_apple 4.5.3, rules_swift 3.6.1. |
| Aesthetic | Polaroid/film | Warm off-whites, grain overlay, editorial serif. Code-only — no asset catalog. |
| Method | Path | Description |
|---|---|---|
GET |
/health |
Liveness check |
GET |
/v1/portfolios/:id |
Portfolio + experiences + skills (typed PortfolioResponse) |
POST |
/v1/portfolios/:id/view |
Increment portfolio view count |
POST |
/v1/portfolios/:id/interested |
Increment portfolio interest count |
GET |
/v1/portfolios/:id/projects |
Project list |
GET |
/v1/projects/:id |
Project detail (pure, cacheable) |
POST |
/v1/projects/:id/view |
Increment project view count |
POST |
/v1/projects/:id/interested |
Increment project interest count |
GET |
/v1/portfolios/:id/qa |
Canned FAQ pairs |
POST |
/v1/portfolios/:id/qa/ask |
Fuzzy-match Q&A |
POST |
/v1/portfolios/:id/ama |
Submit a free-form AMA question |
POST |
/v1/portfolios/:id/notes |
Leave a note |
GET |
/v1/portfolios/:id/notes |
List notes (requires X-Owner-Token) |
GET |
/v1/portfolios/:id/conversations |
Inbox conversations |
GET |
/v1/conversations/:id/messages |
Conversation thread |
POST |
/v1/conversations/:id/messages |
Send a message |
- WAL + tuned pragmas at
init_pool()indb.rs:synchronous = NORMAL,busy_timeout = 5s,foreign_keys = ON,temp_store = MEMORY, 16 MB page cache. - B-tree indexes on every
portfolio_idandconversation_idfilter column.EXPLAIN QUERY PLANreportsSEARCH … USING INDEX. - Concurrent fan-out.
get_portfolioissues 3 queries viatokio::join!. Wall-clock = max(3) instead of sum. - Atomic counters.
UPDATE … SET col = col + 1— no read-modify-write, no transaction needed. - Stripped release binary. Thin LTO +
codegen-units = 1+strip = "symbols"+panic = "abort"produces a ~3.8 MB binary.
docs/system-design.md |
Architecture, data flow, cache strategy, latency considerations, known limitations |
docs/retrospective.md |
Phase-by-phase decisions, tradeoffs, what I'd change with more time |
docs/test-plan.md |
Coverage by tier, what's tested vs. deliberately not |
AGENTS.md |
Conventions for AI coding agents — naming, patterns, common tasks |
CLAUDE.md |
Build/test commands and conventions |
Built by Richard Lao · 24-hour take-home for Lapse.

