[{"data":1,"prerenderedAt":361},["ShallowReactive",2],{"project-03-kata":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\u002F03-kata.md","Kata",{"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,[],"side","\u002Fimages\u002Fprojects\u002Fkata-cover.jpg","2024-03-01","md",{},true,"\u002Fprojects\u002F03-kata","Designer",[],{"title":5,"description":17},[31,32,33],"Figma","CSS","TypeScript","projects\u002F03-kata","— type specimen","Solo",2024,"oQ-K7hD_hsnEC-1mj_A3Ln2vjVvfRXx8RN1mIRS031o",[40,66,190,203,309,336],{"id":41,"title":42,"body":43,"category":51,"cover":52,"date":53,"description":17,"extension":23,"meta":54,"navigation":25,"path":55,"role":27,"screens":56,"seo":57,"stack":58,"stem":62,"subtitle":63,"team":36,"year":64,"__hash__":65},"projects\u002Fprojects\u002F06-hako.md","Hako",{"type":7,"value":44,"toc":49},[45],[10,46,47],{},[13,48,15],{},{"title":17,"searchDepth":18,"depth":18,"links":50},[],"identity","\u002Fimages\u002Fprojects\u002Fhako-cover.jpg","2022-08-01",{},"\u002Fprojects\u002F06-hako",[],{"title":42,"description":17},[59,60,61],"Illustrator","After Effects","Blender","projects\u002F06-hako","— packaging studio",2022,"A-l3Qn-Zp0iTMnV-AN7aIKKweKTUdCOWpbt8IuX3rk0",{"id":67,"title":68,"body":69,"category":171,"cover":172,"date":173,"description":17,"extension":23,"meta":174,"navigation":25,"path":175,"role":176,"screens":177,"seo":180,"stack":181,"stem":185,"subtitle":186,"team":187,"year":188,"__hash__":189},"projects\u002Fprojects\u002F02-hina.md","Hina",{"type":7,"value":70,"toc":166},[71,76,79,82,86,89,92,95,99,102,148,159,162],[72,73,75],"h2",{"id":74},"brief","Brief",[13,77,78],{},"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,80,81],{},"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.",[72,83,85],{"id":84},"process","Process",[13,87,88],{},"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,90,91],{},"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,93,94],{},"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.",[72,96,98],{"id":97},"tech-decisions","Tech Decisions",[13,100,101],{},"The key design challenge was enabling tenant customization without forking the codebase.",[103,104,108],"pre",{"className":105,"code":106,"language":107,"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",[109,110,111,119,124,130,136,142],"code",{"__ignoreMap":17},[112,113,116],"span",{"class":114,"line":115},"line",1,[112,117,118],{},"flowchart TD\n",[112,120,121],{"class":114,"line":18},[112,122,123],{},"    DNS[\"*.studiodomain.com\"] -->|Wildcard DNS| CF[\"Cloudflare Pages\"]\n",[112,125,127],{"class":114,"line":126},3,[112,128,129],{},"    CF -->|Request| Worker[\"Edge Worker (tenant resolution)\"]\n",[112,131,133],{"class":114,"line":132},4,[112,134,135],{},"    Worker -->|Tenant config| App[\"Nuxt SSR App\"]\n",[112,137,139],{"class":114,"line":138},5,[112,140,141],{},"    App -->|schema=tenant_id| PG[\"PostgreSQL (schema-per-tenant)\"]\n",[112,143,145],{"class":114,"line":144},6,[112,146,147],{},"    App -->|Provision| MigWorker[\"Migration Worker (Flyway)\"]\n",[13,149,150,151,154,155,158],{},"The edge worker resolves the tenant from the hostname, injects a config header (",[109,152,153],{},"X-Tenant-ID",", ",[109,156,157],{},"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,160,161],{},"This avoided a per-tenant deployment model while keeping strict data isolation — a deliberate trade-off between operational simplicity and isolation granularity.",[163,164,165],"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":167},[168,169,170],{"id":74,"depth":18,"text":75},{"id":84,"depth":18,"text":85},{"id":97,"depth":18,"text":98},"product","\u002Fimages\u002Fprojects\u002Fhina-cover.jpg","2023-09-01",{},"\u002Fprojects\u002F02-hina","Fullstack Engineer",[178,179],"\u002Fimages\u002Fprojects\u002Fhina-screen-1.jpg","\u002Fimages\u002Fprojects\u002Fhina-screen-2.jpg",{"title":68,"description":17},[33,182,183,184],"Nuxt 3","PostgreSQL","Cloudflare Workers","projects\u002F02-hina","— payments console","5 engineers",2023,"tZlDVwK87s7WoKVojiW0iW7EnuUcJSrbRLrQ3-o7iks",{"id":4,"title":5,"body":191,"category":20,"cover":21,"date":22,"description":17,"extension":23,"meta":199,"navigation":25,"path":26,"role":27,"screens":200,"seo":201,"stack":202,"stem":34,"subtitle":35,"team":36,"year":37,"__hash__":38},{"type":7,"value":192,"toc":197},[193],[10,194,195],{},[13,196,15],{},{"title":17,"searchDepth":18,"depth":18,"links":198},[],{},[],{"title":5,"description":17},[31,32,33],{"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":37,"__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],[72,209,75],{"id":74},[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.",[72,217,85],{"id":84},[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.",[72,228,98],{"id":97},[13,230,231],{},"The core architectural challenge was decoupling sensor ingestion from dashboard delivery without introducing complex distributed systems.",[103,233,235],{"className":105,"code":234,"language":107,"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",[109,236,237,242,247,252,257,262,267],{"__ignoreMap":17},[112,238,239],{"class":114,"line":115},[112,240,241],{},"flowchart LR\n",[112,243,244],{"class":114,"line":18},[112,245,246],{},"    Sensor[\"IoT Sensor (ARM)\"] -->|MQTT| Gateway\n",[112,248,249],{"class":114,"line":126},[112,250,251],{},"    Gateway -->|NATS Publish| Broker[\"NATS Broker\"]\n",[112,253,254],{"class":114,"line":132},[112,255,256],{},"    Broker -->|Subscribe| API[\"Go API Server\"]\n",[112,258,259],{"class":114,"line":138},[112,260,261],{},"    API -->|INSERT| CH[\"ClickHouse\"]\n",[112,263,264],{"class":114,"line":144},[112,265,266],{},"    API -->|SSE| Browser[\"Vue Dashboard\"]\n",[112,268,270],{"class":114,"line":269},7,[112,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 (",[109,280,281],{},"toStartOfInterval",") made rollup queries fast without a separate aggregation pipeline.",[163,284,165],{},{"title":17,"searchDepth":18,"depth":18,"links":286},[287,288,289],{"id":74,"depth":18,"text":75},{"id":84,"depth":18,"text":85},{"id":97,"depth":18,"text":98},"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":171,"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},[33,328,329,330],"Mapbox GL","React","Supabase","projects\u002F04-mori","— map editor","4 engineers",2025,"JVb6P1tYaPxu2ThY1yt3g8SCEyoQOfahj4wBY0l2Zms",{"id":337,"title":338,"body":339,"category":171,"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",1780894152865]