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