[{"data":1,"prerenderedAt":348},["ShallowReactive",2],{"projects-list":3},[4,40,165,190,296,323],{"id":5,"title":6,"body":7,"category":21,"cover":22,"date":23,"description":18,"extension":24,"meta":25,"navigation":26,"path":27,"role":28,"screens":29,"seo":30,"stack":31,"stem":35,"subtitle":36,"team":37,"year":38,"__hash__":39},"projects\u002Fprojects\u002F06-hako.md","Hako",{"type":8,"value":9,"toc":17},"minimark",[10],[11,12,13],"blockquote",{},[14,15,16],"p",{},"WIP — full case study coming.",{"title":18,"searchDepth":19,"depth":19,"links":20},"",2,[],"identity","\u002Fimages\u002Fprojects\u002Fhako-cover.jpg","2022-08-01","md",{},true,"\u002Fprojects\u002F06-hako","Designer",[],{"title":6,"description":18},[32,33,34],"Illustrator","After Effects","Blender","projects\u002F06-hako","— packaging studio","Solo",2022,"A-l3Qn-Zp0iTMnV-AN7aIKKweKTUdCOWpbt8IuX3rk0",{"id":41,"title":42,"body":43,"category":145,"cover":146,"date":147,"description":18,"extension":24,"meta":148,"navigation":26,"path":149,"role":150,"screens":151,"seo":154,"stack":155,"stem":160,"subtitle":161,"team":162,"year":163,"__hash__":164},"projects\u002Fprojects\u002F02-hina.md","Hina",{"type":8,"value":44,"toc":140},[45,50,53,56,60,63,66,69,73,76,122,133,136],[46,47,49],"h2",{"id":48},"brief","Brief",[14,51,52],{},"Hina is a multi-tenant SaaS platform that lets language learning studios manage courses, students, and assignments from a shared infrastructure while keeping tenant data strictly isolated.",[14,54,55],{},"Studio owners configure their subdomain, branding, and curriculum; students access their portal through the branded URL. The platform handles billing, seat limits, and content delivery.",[46,57,59],{"id":58},"process","Process",[14,61,62],{},"The initial architecture used row-level tenant isolation in a single PostgreSQL database. As the tenant count grew past 30, query plans started ignoring indexes due to bloated statistics.",[14,64,65],{},"We migrated to a schema-per-tenant model. Each new tenant gets a dedicated PostgreSQL schema provisioned automatically at signup via a Cloudflare Worker that runs Flyway migrations. This kept query plans predictable and simplified data residency requirements for European customers.",[14,67,68],{},"The frontend is a Nuxt 3 app deployed to Cloudflare Pages, with a shared component library published as a private npm package consumed by each tenant's customizable shell.",[46,70,72],{"id":71},"tech-decisions","Tech Decisions",[14,74,75],{},"The key design challenge was enabling tenant customization without forking the codebase.",[77,78,82],"pre",{"className":79,"code":80,"language":81,"meta":18,"style":18},"language-mermaid shiki shiki-themes github-light github-dark","flowchart TD\n    DNS[\"*.studiodomain.com\"] -->|Wildcard DNS| CF[\"Cloudflare Pages\"]\n    CF -->|Request| Worker[\"Edge Worker (tenant resolution)\"]\n    Worker -->|Tenant config| App[\"Nuxt SSR App\"]\n    App -->|schema=tenant_id| PG[\"PostgreSQL (schema-per-tenant)\"]\n    App -->|Provision| MigWorker[\"Migration Worker (Flyway)\"]\n","mermaid",[83,84,85,93,98,104,110,116],"code",{"__ignoreMap":18},[86,87,90],"span",{"class":88,"line":89},"line",1,[86,91,92],{},"flowchart TD\n",[86,94,95],{"class":88,"line":19},[86,96,97],{},"    DNS[\"*.studiodomain.com\"] -->|Wildcard DNS| CF[\"Cloudflare Pages\"]\n",[86,99,101],{"class":88,"line":100},3,[86,102,103],{},"    CF -->|Request| Worker[\"Edge Worker (tenant resolution)\"]\n",[86,105,107],{"class":88,"line":106},4,[86,108,109],{},"    Worker -->|Tenant config| App[\"Nuxt SSR App\"]\n",[86,111,113],{"class":88,"line":112},5,[86,114,115],{},"    App -->|schema=tenant_id| PG[\"PostgreSQL (schema-per-tenant)\"]\n",[86,117,119],{"class":88,"line":118},6,[86,120,121],{},"    App -->|Provision| MigWorker[\"Migration Worker (Flyway)\"]\n",[14,123,124,125,128,129,132],{},"The edge worker resolves the tenant from the hostname, injects a config header (",[83,126,127],{},"X-Tenant-ID",", ",[83,130,131],{},"X-Tenant-Theme","), and forwards to the Nuxt app. The app reads the header to select the correct PostgreSQL schema and apply the tenant's design tokens at runtime.",[14,134,135],{},"This avoided a per-tenant deployment model while keeping strict data isolation — a deliberate trade-off between operational simplicity and isolation granularity.",[137,138,139],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":18,"searchDepth":19,"depth":19,"links":141},[142,143,144],{"id":48,"depth":19,"text":49},{"id":58,"depth":19,"text":59},{"id":71,"depth":19,"text":72},"product","\u002Fimages\u002Fprojects\u002Fhina-cover.jpg","2023-09-01",{},"\u002Fprojects\u002F02-hina","Fullstack Engineer",[152,153],"\u002Fimages\u002Fprojects\u002Fhina-screen-1.jpg","\u002Fimages\u002Fprojects\u002Fhina-screen-2.jpg",{"title":42,"description":18},[156,157,158,159],"TypeScript","Nuxt 3","PostgreSQL","Cloudflare Workers","projects\u002F02-hina","— payments console","5 engineers",2023,"tZlDVwK87s7WoKVojiW0iW7EnuUcJSrbRLrQ3-o7iks",{"id":166,"title":167,"body":168,"category":176,"cover":177,"date":178,"description":18,"extension":24,"meta":179,"navigation":26,"path":180,"role":28,"screens":181,"seo":182,"stack":183,"stem":186,"subtitle":187,"team":37,"year":188,"__hash__":189},"projects\u002Fprojects\u002F03-kata.md","Kata",{"type":8,"value":169,"toc":174},[170],[11,171,172],{},[14,173,16],{},{"title":18,"searchDepth":19,"depth":19,"links":175},[],"side","\u002Fimages\u002Fprojects\u002Fkata-cover.jpg","2024-03-01",{},"\u002Fprojects\u002F03-kata",[],{"title":167,"description":18},[184,185,156],"Figma","CSS","projects\u002F03-kata","— type specimen",2024,"oQ-K7hD_hsnEC-1mj_A3Ln2vjVvfRXx8RN1mIRS031o",{"id":191,"title":192,"body":193,"category":277,"cover":278,"date":279,"description":18,"extension":24,"meta":280,"navigation":26,"path":281,"role":282,"screens":283,"seo":286,"stack":287,"stem":292,"subtitle":293,"team":294,"year":188,"__hash__":295},"projects\u002Fprojects\u002F01-field.md","Field",{"type":8,"value":194,"toc":272},[195,197,200,203,205,208,211,214,216,219,260,263,270],[46,196,49],{"id":48},[14,198,199],{},"Field Monitor is a real-time telemetry dashboard for agricultural IoT sensors deployed at the network edge. Farmers and agronomists use it to track soil moisture, temperature, and humidity across multiple zones from a single interface.",[14,201,202],{},"The system handles thousands of concurrent sensor connections, persists time-series data efficiently, and delivers live updates to browser clients without polling.",[46,204,59],{"id":58},[14,206,207],{},"The project started with a proof-of-concept using WebSockets and PostgreSQL. Early load tests showed that relational storage couldn't keep up with the write throughput from 500+ concurrent sensors.",[14,209,210],{},"We migrated to ClickHouse for time-series persistence and introduced NATS as the message broker between edge agents and the backend API. The frontend moved to Server-Sent Events, which simplified client reconnection logic and reduced server overhead.",[14,212,213],{},"Deployment runs on bare-metal nodes at the farm co-location, with a lightweight Go agent compiled for ARMv7 on each sensor gateway.",[46,215,72],{"id":71},[14,217,218],{},"The core architectural challenge was decoupling sensor ingestion from dashboard delivery without introducing complex distributed systems.",[77,220,222],{"className":79,"code":221,"language":81,"meta":18,"style":18},"flowchart LR\n    Sensor[\"IoT Sensor (ARM)\"] -->|MQTT| Gateway\n    Gateway -->|NATS Publish| Broker[\"NATS Broker\"]\n    Broker -->|Subscribe| API[\"Go API Server\"]\n    API -->|INSERT| CH[\"ClickHouse\"]\n    API -->|SSE| Browser[\"Vue Dashboard\"]\n    CH -->|Query| API\n",[83,223,224,229,234,239,244,249,254],{"__ignoreMap":18},[86,225,226],{"class":88,"line":89},[86,227,228],{},"flowchart LR\n",[86,230,231],{"class":88,"line":19},[86,232,233],{},"    Sensor[\"IoT Sensor (ARM)\"] -->|MQTT| Gateway\n",[86,235,236],{"class":88,"line":100},[86,237,238],{},"    Gateway -->|NATS Publish| Broker[\"NATS Broker\"]\n",[86,240,241],{"class":88,"line":106},[86,242,243],{},"    Broker -->|Subscribe| API[\"Go API Server\"]\n",[86,245,246],{"class":88,"line":112},[86,247,248],{},"    API -->|INSERT| CH[\"ClickHouse\"]\n",[86,250,251],{"class":88,"line":118},[86,252,253],{},"    API -->|SSE| Browser[\"Vue Dashboard\"]\n",[86,255,257],{"class":88,"line":256},7,[86,258,259],{},"    CH -->|Query| API\n",[14,261,262],{},"NATS was chosen over Kafka because the message volume didn't justify Kafka's operational overhead, and NATS's at-most-once delivery was acceptable for live telemetry (missing one reading is fine; duplicating one is not).",[14,264,265,266,269],{},"ClickHouse's columnar storage and built-in time-bucketing functions (",[83,267,268],{},"toStartOfInterval",") made rollup queries fast without a separate aggregation pipeline.",[137,271,139],{},{"title":18,"searchDepth":19,"depth":19,"links":273},[274,275,276],{"id":48,"depth":19,"text":49},{"id":58,"depth":19,"text":59},{"id":71,"depth":19,"text":72},"system","\u002Fimages\u002Fprojects\u002Ffield-cover.jpg","2024-06-01",{},"\u002Fprojects\u002F01-field","Backend Lead",[284,285],"\u002Fimages\u002Fprojects\u002Ffield-screen-1.jpg","\u002Fimages\u002Fprojects\u002Ffield-screen-2.jpg",{"title":192,"description":18},[288,289,290,291],"Go","NATS","ClickHouse","Vue 3","projects\u002F01-field","— design system","3 engineers","SF5d6pcMyy-L_fIPkBofcNbAKF-Awa5aSmJI5tNunDE",{"id":297,"title":298,"body":299,"category":145,"cover":307,"date":308,"description":18,"extension":24,"meta":309,"navigation":26,"path":310,"role":311,"screens":312,"seo":313,"stack":314,"stem":318,"subtitle":319,"team":320,"year":321,"__hash__":322},"projects\u002Fprojects\u002F04-mori.md","Mori",{"type":8,"value":300,"toc":305},[301],[11,302,303],{},[14,304,16],{},{"title":18,"searchDepth":19,"depth":19,"links":306},[],"\u002Fimages\u002Fprojects\u002Fmori-cover.jpg","2025-01-01",{},"\u002Fprojects\u002F04-mori","Frontend Engineer",[],{"title":298,"description":18},[156,315,316,317],"Mapbox GL","React","Supabase","projects\u002F04-mori","— map editor","4 engineers",2025,"JVb6P1tYaPxu2ThY1yt3g8SCEyoQOfahj4wBY0l2Zms",{"id":324,"title":325,"body":326,"category":145,"cover":334,"date":335,"description":18,"extension":24,"meta":336,"navigation":26,"path":337,"role":338,"screens":339,"seo":340,"stack":341,"stem":345,"subtitle":346,"team":294,"year":321,"__hash__":347},"projects\u002Fprojects\u002F05-foglight.md","Foglight",{"type":8,"value":327,"toc":332},[328],[11,329,330],{},[14,331,16],{},{"title":18,"searchDepth":19,"depth":19,"links":333},[],"\u002Fimages\u002Fprojects\u002Ffoglight-cover.jpg","2025-05-01",{},"\u002Fprojects\u002F05-foglight","Platform Engineer",[],{"title":325,"description":18},[288,342,343,344],"Prometheus","Grafana","Kubernetes","projects\u002F05-foglight","— observability","eMl4j-uS1Z3VCswCpdYfg-efhBJBkUpbg1dQ2lVZk1Q",1780894152479]