[{"data":1,"prerenderedAt":361},["ShallowReactive",2],{"project-06-hako":3,"projects-all":39},{"id":4,"title":5,"body":6,"category":20,"cover":21,"date":22,"description":17,"extension":23,"meta":24,"navigation":25,"path":26,"role":27,"screens":28,"seo":29,"stack":30,"stem":34,"subtitle":35,"team":36,"year":37,"__hash__":38},"projects\u002Fprojects\u002F06-hako.md","Hako",{"type":7,"value":8,"toc":16},"minimark",[9],[10,11,12],"blockquote",{},[13,14,15],"p",{},"WIP — full case study coming.",{"title":17,"searchDepth":18,"depth":18,"links":19},"",2,[],"identity","\u002Fimages\u002Fprojects\u002Fhako-cover.jpg","2022-08-01","md",{},true,"\u002Fprojects\u002F06-hako","Designer",[],{"title":5,"description":17},[31,32,33],"Illustrator","After Effects","Blender","projects\u002F06-hako","— packaging studio","Solo",2022,"A-l3Qn-Zp0iTMnV-AN7aIKKweKTUdCOWpbt8IuX3rk0",[40,53,178,203,309,336],{"id":4,"title":5,"body":41,"category":20,"cover":21,"date":22,"description":17,"extension":23,"meta":49,"navigation":25,"path":26,"role":27,"screens":50,"seo":51,"stack":52,"stem":34,"subtitle":35,"team":36,"year":37,"__hash__":38},{"type":7,"value":42,"toc":47},[43],[10,44,45],{},[13,46,15],{},{"title":17,"searchDepth":18,"depth":18,"links":48},[],{},[],{"title":5,"description":17},[31,32,33],{"id":54,"title":55,"body":56,"category":158,"cover":159,"date":160,"description":17,"extension":23,"meta":161,"navigation":25,"path":162,"role":163,"screens":164,"seo":167,"stack":168,"stem":173,"subtitle":174,"team":175,"year":176,"__hash__":177},"projects\u002Fprojects\u002F02-hina.md","Hina",{"type":7,"value":57,"toc":153},[58,63,66,69,73,76,79,82,86,89,135,146,149],[59,60,62],"h2",{"id":61},"brief","Brief",[13,64,65],{},"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.",[13,67,68],{},"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.",[59,70,72],{"id":71},"process","Process",[13,74,75],{},"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.",[13,77,78],{},"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.",[13,80,81],{},"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.",[59,83,85],{"id":84},"tech-decisions","Tech Decisions",[13,87,88],{},"The key design challenge was enabling tenant customization without forking the codebase.",[90,91,95],"pre",{"className":92,"code":93,"language":94,"meta":17,"style":17},"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",[96,97,98,106,111,117,123,129],"code",{"__ignoreMap":17},[99,100,103],"span",{"class":101,"line":102},"line",1,[99,104,105],{},"flowchart TD\n",[99,107,108],{"class":101,"line":18},[99,109,110],{},"    DNS[\"*.studiodomain.com\"] -->|Wildcard DNS| CF[\"Cloudflare Pages\"]\n",[99,112,114],{"class":101,"line":113},3,[99,115,116],{},"    CF -->|Request| Worker[\"Edge Worker (tenant resolution)\"]\n",[99,118,120],{"class":101,"line":119},4,[99,121,122],{},"    Worker -->|Tenant config| App[\"Nuxt SSR App\"]\n",[99,124,126],{"class":101,"line":125},5,[99,127,128],{},"    App -->|schema=tenant_id| PG[\"PostgreSQL (schema-per-tenant)\"]\n",[99,130,132],{"class":101,"line":131},6,[99,133,134],{},"    App -->|Provision| MigWorker[\"Migration Worker (Flyway)\"]\n",[13,136,137,138,141,142,145],{},"The edge worker resolves the tenant from the hostname, injects a config header (",[96,139,140],{},"X-Tenant-ID",", ",[96,143,144],{},"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.",[13,147,148],{},"This avoided a per-tenant deployment model while keeping strict data isolation — a deliberate trade-off between operational simplicity and isolation granularity.",[150,151,152],"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":17,"searchDepth":18,"depth":18,"links":154},[155,156,157],{"id":61,"depth":18,"text":62},{"id":71,"depth":18,"text":72},{"id":84,"depth":18,"text":85},"product","\u002Fimages\u002Fprojects\u002Fhina-cover.jpg","2023-09-01",{},"\u002Fprojects\u002F02-hina","Fullstack Engineer",[165,166],"\u002Fimages\u002Fprojects\u002Fhina-screen-1.jpg","\u002Fimages\u002Fprojects\u002Fhina-screen-2.jpg",{"title":55,"description":17},[169,170,171,172],"TypeScript","Nuxt 3","PostgreSQL","Cloudflare Workers","projects\u002F02-hina","— payments console","5 engineers",2023,"tZlDVwK87s7WoKVojiW0iW7EnuUcJSrbRLrQ3-o7iks",{"id":179,"title":180,"body":181,"category":189,"cover":190,"date":191,"description":17,"extension":23,"meta":192,"navigation":25,"path":193,"role":27,"screens":194,"seo":195,"stack":196,"stem":199,"subtitle":200,"team":36,"year":201,"__hash__":202},"projects\u002Fprojects\u002F03-kata.md","Kata",{"type":7,"value":182,"toc":187},[183],[10,184,185],{},[13,186,15],{},{"title":17,"searchDepth":18,"depth":18,"links":188},[],"side","\u002Fimages\u002Fprojects\u002Fkata-cover.jpg","2024-03-01",{},"\u002Fprojects\u002F03-kata",[],{"title":180,"description":17},[197,198,169],"Figma","CSS","projects\u002F03-kata","— type specimen",2024,"oQ-K7hD_hsnEC-1mj_A3Ln2vjVvfRXx8RN1mIRS031o",{"id":204,"title":205,"body":206,"category":290,"cover":291,"date":292,"description":17,"extension":23,"meta":293,"navigation":25,"path":294,"role":295,"screens":296,"seo":299,"stack":300,"stem":305,"subtitle":306,"team":307,"year":201,"__hash__":308},"projects\u002Fprojects\u002F01-field.md","Field",{"type":7,"value":207,"toc":285},[208,210,213,216,218,221,224,227,229,232,273,276,283],[59,209,62],{"id":61},[13,211,212],{},"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.",[13,214,215],{},"The system handles thousands of concurrent sensor connections, persists time-series data efficiently, and delivers live updates to browser clients without polling.",[59,217,72],{"id":71},[13,219,220],{},"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.",[13,222,223],{},"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.",[13,225,226],{},"Deployment runs on bare-metal nodes at the farm co-location, with a lightweight Go agent compiled for ARMv7 on each sensor gateway.",[59,228,85],{"id":84},[13,230,231],{},"The core architectural challenge was decoupling sensor ingestion from dashboard delivery without introducing complex distributed systems.",[90,233,235],{"className":92,"code":234,"language":94,"meta":17,"style":17},"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",[96,236,237,242,247,252,257,262,267],{"__ignoreMap":17},[99,238,239],{"class":101,"line":102},[99,240,241],{},"flowchart LR\n",[99,243,244],{"class":101,"line":18},[99,245,246],{},"    Sensor[\"IoT Sensor (ARM)\"] -->|MQTT| Gateway\n",[99,248,249],{"class":101,"line":113},[99,250,251],{},"    Gateway -->|NATS Publish| Broker[\"NATS Broker\"]\n",[99,253,254],{"class":101,"line":119},[99,255,256],{},"    Broker -->|Subscribe| API[\"Go API Server\"]\n",[99,258,259],{"class":101,"line":125},[99,260,261],{},"    API -->|INSERT| CH[\"ClickHouse\"]\n",[99,263,264],{"class":101,"line":131},[99,265,266],{},"    API -->|SSE| Browser[\"Vue Dashboard\"]\n",[99,268,270],{"class":101,"line":269},7,[99,271,272],{},"    CH -->|Query| API\n",[13,274,275],{},"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).",[13,277,278,279,282],{},"ClickHouse's columnar storage and built-in time-bucketing functions (",[96,280,281],{},"toStartOfInterval",") made rollup queries fast without a separate aggregation pipeline.",[150,284,152],{},{"title":17,"searchDepth":18,"depth":18,"links":286},[287,288,289],{"id":61,"depth":18,"text":62},{"id":71,"depth":18,"text":72},{"id":84,"depth":18,"text":85},"system","\u002Fimages\u002Fprojects\u002Ffield-cover.jpg","2024-06-01",{},"\u002Fprojects\u002F01-field","Backend Lead",[297,298],"\u002Fimages\u002Fprojects\u002Ffield-screen-1.jpg","\u002Fimages\u002Fprojects\u002Ffield-screen-2.jpg",{"title":205,"description":17},[301,302,303,304],"Go","NATS","ClickHouse","Vue 3","projects\u002F01-field","— design system","3 engineers","SF5d6pcMyy-L_fIPkBofcNbAKF-Awa5aSmJI5tNunDE",{"id":310,"title":311,"body":312,"category":158,"cover":320,"date":321,"description":17,"extension":23,"meta":322,"navigation":25,"path":323,"role":324,"screens":325,"seo":326,"stack":327,"stem":331,"subtitle":332,"team":333,"year":334,"__hash__":335},"projects\u002Fprojects\u002F04-mori.md","Mori",{"type":7,"value":313,"toc":318},[314],[10,315,316],{},[13,317,15],{},{"title":17,"searchDepth":18,"depth":18,"links":319},[],"\u002Fimages\u002Fprojects\u002Fmori-cover.jpg","2025-01-01",{},"\u002Fprojects\u002F04-mori","Frontend Engineer",[],{"title":311,"description":17},[169,328,329,330],"Mapbox GL","React","Supabase","projects\u002F04-mori","— map editor","4 engineers",2025,"JVb6P1tYaPxu2ThY1yt3g8SCEyoQOfahj4wBY0l2Zms",{"id":337,"title":338,"body":339,"category":158,"cover":347,"date":348,"description":17,"extension":23,"meta":349,"navigation":25,"path":350,"role":351,"screens":352,"seo":353,"stack":354,"stem":358,"subtitle":359,"team":307,"year":334,"__hash__":360},"projects\u002Fprojects\u002F05-foglight.md","Foglight",{"type":7,"value":340,"toc":345},[341],[10,342,343],{},[13,344,15],{},{"title":17,"searchDepth":18,"depth":18,"links":346},[],"\u002Fimages\u002Fprojects\u002Ffoglight-cover.jpg","2025-05-01",{},"\u002Fprojects\u002F05-foglight","Platform Engineer",[],{"title":338,"description":17},[301,355,356,357],"Prometheus","Grafana","Kubernetes","projects\u002F05-foglight","— observability","eMl4j-uS1Z3VCswCpdYfg-efhBJBkUpbg1dQ2lVZk1Q",1780894152861]