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