[{"data":1,"prerenderedAt":361},["ShallowReactive",2],{"project-05-foglight":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\u002F05-foglight.md","Foglight",{"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\u002Ffoglight-cover.jpg","2025-05-01","md",{},true,"\u002Fprojects\u002F05-foglight","Platform Engineer",[],{"title":5,"description":17},[31,32,33,34],"Go","Prometheus","Grafana","Kubernetes","projects\u002F05-foglight","— observability","3 engineers",2025,"eMl4j-uS1Z3VCswCpdYfg-efhBJBkUpbg1dQ2lVZk1Q",[41,69,193,218,322,348],{"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":188,"subtitle":189,"team":190,"year":191,"__hash__":192},"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},[184,185,186,187],"TypeScript","Nuxt 3","PostgreSQL","Cloudflare Workers","projects\u002F02-hina","— payments console","5 engineers",2023,"tZlDVwK87s7WoKVojiW0iW7EnuUcJSrbRLrQ3-o7iks",{"id":194,"title":195,"body":196,"category":204,"cover":205,"date":206,"description":17,"extension":23,"meta":207,"navigation":25,"path":208,"role":57,"screens":209,"seo":210,"stack":211,"stem":214,"subtitle":215,"team":66,"year":216,"__hash__":217},"projects\u002Fprojects\u002F03-kata.md","Kata",{"type":7,"value":197,"toc":202},[198],[10,199,200],{},[13,201,15],{},{"title":17,"searchDepth":18,"depth":18,"links":203},[],"side","\u002Fimages\u002Fprojects\u002Fkata-cover.jpg","2024-03-01",{},"\u002Fprojects\u002F03-kata",[],{"title":195,"description":17},[212,213,184],"Figma","CSS","projects\u002F03-kata","— type specimen",2024,"oQ-K7hD_hsnEC-1mj_A3Ln2vjVvfRXx8RN1mIRS031o",{"id":219,"title":220,"body":221,"category":305,"cover":306,"date":307,"description":17,"extension":23,"meta":308,"navigation":25,"path":309,"role":310,"screens":311,"seo":314,"stack":315,"stem":319,"subtitle":320,"team":37,"year":216,"__hash__":321},"projects\u002Fprojects\u002F01-field.md","Field",{"type":7,"value":222,"toc":300},[223,225,228,231,233,236,239,242,244,247,288,291,298],[75,224,78],{"id":77},[13,226,227],{},"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,229,230],{},"The system handles thousands of concurrent sensor connections, persists time-series data efficiently, and delivers live updates to browser clients without polling.",[75,232,88],{"id":87},[13,234,235],{},"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,237,238],{},"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,240,241],{},"Deployment runs on bare-metal nodes at the farm co-location, with a lightweight Go agent compiled for ARMv7 on each sensor gateway.",[75,243,101],{"id":100},[13,245,246],{},"The core architectural challenge was decoupling sensor ingestion from dashboard delivery without introducing complex distributed systems.",[106,248,250],{"className":108,"code":249,"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,251,252,257,262,267,272,277,282],{"__ignoreMap":17},[115,253,254],{"class":117,"line":118},[115,255,256],{},"flowchart LR\n",[115,258,259],{"class":117,"line":18},[115,260,261],{},"    Sensor[\"IoT Sensor (ARM)\"] -->|MQTT| Gateway\n",[115,263,264],{"class":117,"line":129},[115,265,266],{},"    Gateway -->|NATS Publish| Broker[\"NATS Broker\"]\n",[115,268,269],{"class":117,"line":135},[115,270,271],{},"    Broker -->|Subscribe| API[\"Go API Server\"]\n",[115,273,274],{"class":117,"line":141},[115,275,276],{},"    API -->|INSERT| CH[\"ClickHouse\"]\n",[115,278,279],{"class":117,"line":147},[115,280,281],{},"    API -->|SSE| Browser[\"Vue Dashboard\"]\n",[115,283,285],{"class":117,"line":284},7,[115,286,287],{},"    CH -->|Query| API\n",[13,289,290],{},"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,292,293,294,297],{},"ClickHouse's columnar storage and built-in time-bucketing functions (",[112,295,296],{},"toStartOfInterval",") made rollup queries fast without a separate aggregation pipeline.",[166,299,168],{},{"title":17,"searchDepth":18,"depth":18,"links":301},[302,303,304],{"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",[312,313],"\u002Fimages\u002Fprojects\u002Ffield-screen-1.jpg","\u002Fimages\u002Fprojects\u002Ffield-screen-2.jpg",{"title":220,"description":17},[31,316,317,318],"NATS","ClickHouse","Vue 3","projects\u002F01-field","— design system","SF5d6pcMyy-L_fIPkBofcNbAKF-Awa5aSmJI5tNunDE",{"id":323,"title":324,"body":325,"category":20,"cover":333,"date":334,"description":17,"extension":23,"meta":335,"navigation":25,"path":336,"role":337,"screens":338,"seo":339,"stack":340,"stem":344,"subtitle":345,"team":346,"year":38,"__hash__":347},"projects\u002Fprojects\u002F04-mori.md","Mori",{"type":7,"value":326,"toc":331},[327],[10,328,329],{},[13,330,15],{},{"title":17,"searchDepth":18,"depth":18,"links":332},[],"\u002Fimages\u002Fprojects\u002Fmori-cover.jpg","2025-01-01",{},"\u002Fprojects\u002F04-mori","Frontend Engineer",[],{"title":324,"description":17},[184,341,342,343],"Mapbox GL","React","Supabase","projects\u002F04-mori","— map editor","4 engineers","JVb6P1tYaPxu2ThY1yt3g8SCEyoQOfahj4wBY0l2Zms",{"id":4,"title":5,"body":349,"category":20,"cover":21,"date":22,"description":17,"extension":23,"meta":357,"navigation":25,"path":26,"role":27,"screens":358,"seo":359,"stack":360,"stem":35,"subtitle":36,"team":37,"year":38,"__hash__":39},{"type":7,"value":350,"toc":355},[351],[10,352,353],{},[13,354,15],{},{"title":17,"searchDepth":18,"depth":18,"links":356},[],{},[],{"title":5,"description":17},[31,32,33,34],1780894152869]