Skip to content

Latest commit

 

History

64 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

emulsion

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.

Rust Swift iOS axum SQLite Bazel tests


TLDR Card Inbox

Contents

  1. What this is
  2. Repository structure
  3. How to build and run
  4. How to run tests
  5. Assumptions and limitations
  6. Architecture
  7. Highlights
  8. Stack
  9. API
  10. Performance
  11. Documentation

What this is

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.

Repository structure

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

How to build and run

macOS — full stack (recommended)

./run.sh

This 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 Simulator

Bazel (canonical monorepo build)

bazel build //services/portfolio-api:server         # Rust binary
bazel build //apps/ios:app                           # iOS .ipa
bazel build //shared/emulsion-types:emulsion_types   # Shared types crate

Bazel and Cargo coexist intentionally — Bazel is the canonical build, Cargo is for fast inner-loop iteration.

Manual (Cargo + Xcode)

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.xcodeproj

Windows (backend only)

run.bat

iOS requires macOS + Xcode.

How to run tests

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 tests

Full plan, including what isn't tested and why: docs/test-plan.md.

Assumptions and limitations

Assumptions baked in:

  • Single user, single server, localhost only. No multi-tenancy, no auth beyond an X-Owner-Token header 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 to is_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. DashMap is 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_test painful. iOS tests run via xcodebuild test only; bazel test //... covers the Rust suites.
  • tools/seed uses compile-time path macros (sqlx::migrate!, include_str!) that don't resolve in Bazel's sandbox; tagged manual and run via Cargo only.

What I'd change with more time is documented in docs/retrospective.md.


Architecture

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
Loading

Read path. PortfolioViewModel.load()URLSessionGET /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 + 1cache.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.

Highlights

  • End-to-end working system. Every screen hits a real backend.
  • Shared platform layer is wired, not decorative. Backend get_portfolio returns Json<emulsion_types::PortfolioResponse>. From<RowType> for emulsion_types::CanonicalType impls 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. DashMap lock-free in-process cache with prefix invalidation. Keys live in a typed cache::keys module.
  • 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::oneshot exercises real handler + extractor + JSON wiring. All 6 iOS ViewModels mocked through APIClientProtocol. Shared types have a wire-format regression guard.
  • Agent-ready. AGENTS.md documents conventions, file layout, and patterns for AI coding agents.

Stack

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.

API

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

Performance

  • WAL + tuned pragmas at init_pool() in db.rs: synchronous = NORMAL, busy_timeout = 5s, foreign_keys = ON, temp_store = MEMORY, 16 MB page cache.
  • B-tree indexes on every portfolio_id and conversation_id filter column. EXPLAIN QUERY PLAN reports SEARCH … USING INDEX.
  • Concurrent fan-out. get_portfolio issues 3 queries via tokio::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.

Documentation

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.

About

Full-stack portfolio platform — Rust/Axum API, SwiftUI iOS app, SQLite WAL, UniFFI shared types

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages