<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
  <title>Panth Patel</title>
  <link>https://panth.vardayinitech.in/blog</link>
  <atom:link href="https://panth.vardayinitech.in/rss.xml" rel="self" type="application/rss+xml"/>
  <description>Developer tooling, agent-driven pipelines, backend and platform engineering. Notes from building Envizom at Oizom.</description>
  <language>en</language>
  <item>
  <title>IceBase: Firebase&apos;s API, a declared schema and a way out</title>
  <link>https://panth.vardayinitech.in/blog/icebase-firebase-api-with-a-way-out</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/icebase-firebase-api-with-a-way-out</guid>
  <pubDate>Thu, 12 Nov 2026 00:00:00 GMT</pubDate>
  <description>Firebase is my go-to for side projects and there is no overnight way off it. IceBase keeps Firebase&apos;s API, requires a declared schema and indexes, and runs on firebase, SQLite or Postgres per service.</description>
  <category>firebase</category><category>typescript</category><category>postgres</category><category>open-source</category>
  <content:encoded><![CDATA[<p>IceBase keeps Firebase&#39;s API and makes the backend a config value: <code>firebase</code>, <code>emulator</code> on SQLite, or <code>vm</code> on Postgres, per service. It is Apache-2.0, on JSR, and at v0.6.0: <a href="https://github.com/Panth977/icebase">github.com/Panth977/icebase</a>. The full overnight move off Firebase is not finished, and I&#39;ll say below what is missing.</p>
<p>I&#39;m Panth Patel. I build a lot of side projects, and Firebase has been my go-to for them. It has everything, and the free tier is generous.</p>
<h2>The problem shows up late</h2>
<p>The trouble starts when a side project stops being one. Once I wanted to run something for a company or at scale, I found no overnight way off. The app is written against Firebase&#39;s SDK, and the schema exists only in the data.</p>
<p>So I wanted a layer I could adopt on day one, while Firebase is still the right choice, that keeps the exit open.</p>
<h2>Same calls, one more file</h2>
<p>The function names and call shapes are Firebase&#39;s. The imports change:</p>
<pre><code class="language-ts">import { collection, getDocs, orderBy, query, where } from &#39;@panth977/icebase/firestore&#39;;
import { db } from &#39;./lib/icebase&#39;;
import { products } from &#39;../icebase/schema&#39;;

const q = query(collection(db, products), where(&#39;category&#39;, &#39;==&#39;, &#39;shoes&#39;), orderBy(&#39;priceCents&#39;));
const snap = await getDocs(q);   // snap.docs[0].data() is typed from the schema
</code></pre>
<p>The one difference you&#39;ll feel is key paths. Nested fields are arrays, <code>where([&#39;address&#39;, &#39;city&#39;], ...)</code>, because <code>&#39;rating.avg&#39;</code> is one key.</p>
<p><code>icebase.config.ts</code> is the new file. It holds the schema, rules, functions, hosting and, per environment, the mode of each service:</p>
<pre><code class="language-ts">environments: {
  local: { mode: &#39;emulator&#39; },
  prod: { mode: &#39;firebase&#39; },
}
</code></pre>
<h2>The schema is the real feature</h2>
<p>Firebase accepts anything, and that is why it is fast to start. AI makes this sharper. Building with AI is fine, but a good developer still looks at the interfaces and the schema to find bugs before the build. A loose database gives you nothing to look at.</p>
<p>IceBase asks for the exact shape of every collection, Realtime Database path and storage path, and for every field you filter on. Writes are type-checked and validated at runtime in every mode. A query whose index isn&#39;t declared fails at compile time:</p>
<pre><code>MissingIndex&lt;&quot;category&quot; | &quot;stock&quot;&gt;
</code></pre>
<p>It is also refused at runtime, in every mode, and <code>icebase indexes check</code> lists every such query for CI.</p>
<p>The later payoff is the migration. On the day you move to Postgres, you don&#39;t need to run an AI over your data to discover what the schema was. It has been in your repo the whole time.</p>
<p>If the project already exists, <code>icebase init --import DIR --project ID --sample N</code> drafts a schema from sampled documents, and learning mode reports undeclared collections and missing indexes instead of refusing them.</p>
<h2>Three drivers</h2>
<ul>
<li><strong>firebase</strong>: the real Firebase SDKs, with the type, schema and index checks on top.</li>
<li><strong>emulator</strong>: my own IceBase server on SQLite. I found Firebase&#39;s emulator slow and wanted one good enough for production-like testing. It enforces your rules in Firebase&#39;s own syntax, and those same files deploy unchanged to Firebase.</li>
<li><strong>vm</strong>: the same server on Postgres, self-hosted.</li>
</ul>
<p>Modes mix per service, so one service can move while the rest stay put. Going between the two server stores is one command, in either direction:</p>
<pre><code class="language-sh">icebase migrate --from sqlite:.icebase --to env:DATABASE_URL
</code></pre>
<p>IceBase also has its own console for every mode, so you don&#39;t have to open Firebase&#39;s.</p>
<p>To keep me honest, the conformance suites run against Firebase&#39;s emulators, the IceBase server and a live Firebase project, and every IceBase-server suite also passes on Postgres. My design editor, <a href="https://github.com/Panth977/digital-canvas">Digital Canvas</a>, runs on IceBase in firebase mode.</p>
<h2>What is not built</h2>
<ul>
<li>S3 for stored files. In vm mode they stay on disk today, and <code>icebase migrate</code> does not copy them.</li>
<li>A Docker image, login and deploy to a VM, and backups.</li>
<li>Import, export and link. These are what will make the move from a live Firebase project an overnight job, and they are next on the roadmap.</li>
</ul>
<p>So the overnight move is the goal, and the pieces under it are in place. It is not a claim about today.</p>
<p>Have you moved a real app off Firebase? What was the part you did not see coming?</p>
]]></content:encoded>
</item>
<item>
  <title>I built an open-source Canva alternative with 42 MCP tools</title>
  <link>https://panth.vardayinitech.in/blog/digital-canvas-open-source-design-editor</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/digital-canvas-open-source-design-editor</guid>
  <pubDate>Tue, 10 Nov 2026 00:00:00 GMT</pubDate>
  <description>Digital Canvas is an Apache-2.0 design editor on Skia WASM. Designs are .dcanvas files you own, it opens without a login, and agents can drive it through 42 MCP tools.</description>
  <category>open-source</category><category>svelte</category><category>webassembly</category><category>mcp</category>
  <content:encoded><![CDATA[<p>Digital Canvas is a free design editor, Apache-2.0, and it opens with no login. My first commit was on 27 October and there were 160 by the 31st. The goal is everything Canva has, with your designs stored as files you own: <a href="https://github.com/Panth977/digital-canvas">github.com/Panth977/digital-canvas</a>. You can <a href="https://digital-canvas-p977.web.app">try it here</a>.</p>
<p>I&#39;m Panth Patel. I build backends and developer tools, and this is a project of my own.</p>
<h2>Why build this at all</h2>
<p>As a graphics editor, Canva is simple. That is a compliment, and it is also why I think the editor should be free. The templates are great and they may never be free, because someone made them. The framework, the animations and the kit are different. Those are software, and software can be opened.</p>
<p>So I split the problem in two: an open editor now, and a template catalogue that grows over time. I set one rule on the content side. Nothing in the library is generated. Templates, stickers and palettes come from open catalogues, with credit, and the 62 mockups are real photographs.</p>
<h2>The file is the product</h2>
<p>A design is one <code>.dcanvas</code> file. It is a zip:</p>
<pre><code>mimetype         application/vnd.digital-canvas+zip
document.json    { format: &quot;digital-canvas&quot;, version: 1, width, height, pages: [...], assets: {...} }
assets/&lt;hash&gt;    image bytes, named by content hash
thumbnail.png    preview of page 1
</code></pre>
<p><code>document.json</code> is plain, documented JSON, so a script can read and write a design without the editor. You choose where the files live: this browser, a folder on your computer, or your own Google Drive. Drive access uses the <code>drive.file</code> scope only, so the app sees what it created and what you picked.</p>
<p>The backend stores a user, a login count and a Drive refresh token. Your designs never pass through it. That backend runs on <a href="https://github.com/Panth977/icebase">IceBase</a>, which is another thing I built and a separate post.</p>
<h2>One renderer everywhere</h2>
<p>Drawing goes through CanvasKit, which is Skia compiled to WebAssembly. The UI around it is Svelte 5. <code>packages/render</code> has no DOM in it, so the same code draws in the browser and in Node, and the unit tests render real PNGs.</p>
<p>That choice pays off in export. Live playback, the web MP4 export and the agent&#39;s exporter all call the same frame function, so what you see while scrubbing is what lands in the file. PNG, JPG, SVG, PDF (raster or vector) and MP4 all come out of this one engine.</p>
<p>Today that covers 2,000+ Google Fonts, 270+ size presets, 17 adjustments, 35 filters, tables, charts, QR codes and a video timeline. The background remover (BiRefNet) and Magic Eraser (LaMa) run on your device. The AI features use your own key, and that key goes only to its provider.</p>
<h2>Give it to your agent</h2>
<p>This is the part I care about most. You spend hours learning an editor. An agent should be able to use that same editor for you, and leave you a design you can open and finish by hand.</p>
<p><code>packages/mcp</code> is an MCP server with 42 tools. Any client with remote MCP and OAuth can connect with one URL:</p>
<pre><code class="language-sh">claude mcp add --transport http digital-canvas https://digital-canvas-p977.web.app/mcp
</code></pre>
<p>The agent searches your uploads and free stock, composes the page, calls <code>render_page</code> to look at the result, and calls <code>check_design</code>, which measures overflow, contrast on the real pixels, cut-off text and tiny fonts. Then it fixes what failed and exports. The tools run on your own device, and the server is only a relay.</p>
<p>That loop is why it has eyes: the agent sees what it made and gets a measured critique before it hands anything back. The result is an ordinary <code>.dcanvas</code> file, not a flattened image.</p>
<h2>What is not there yet</h2>
<p>It is web only today. The next phase is a version you run locally from any folder, with no login and no Drive setup. After that come the tools people expect from a modern editor: Instagram-style edit animations, hooks, more mockups and many more templates.</p>
<p>The repo is Apache-2.0. If you try it, I&#39;d like to know one thing: what is the first thing you tried to do that it could not?</p>
]]></content:encoded>
</item>
<item>
  <title>Three ways into a client&apos;s on-prem server, and where the client&apos;s switch sits</title>
  <link>https://panth.vardayinitech.in/blog/opm-client-holds-the-switch</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/opm-client-holds-the-switch</guid>
  <pubDate>Thu, 05 Nov 2026 00:00:00 GMT</pubDate>
  <description>On-Prem Manager can now reach a client&apos;s server through an encrypted browser tunnel, the agent GUI on the cloud&apos;s own domain, and port tunnels. What each one does, what it doesn&apos;t hide, and how the client turns it off.</description>
  <category>devops</category><category>security</category><category>docker</category><category>open-source</category>
  <content:encoded><![CDATA[<p>A government client&#39;s server is not my server. On-Prem Manager (OPM) can now reach one in three ways, so the useful question for each is who can turn it off.</p>
<p>OPM is the open-source tool I built to run on-premise Docker deployments: a cloud console, and one Go agent per server with its own GUI. Three things changed.</p>
<h2>1. The browser tunnel is end-to-end encrypted</h2>
<p>Some sites have no internet at all. For those, the agent borrows the browser of the person using its GUI: the tab fetches from the cloud on the agent&#39;s behalf.</p>
<p>Until this change, every request and response passed through that tab in clear. That included commits with secret values, and on a LAN the agent GUI may be plain http.</p>
<p>Now the agent seals each request to an X25519 key on the cloud, and that key has to be certified by a release key pinned in the agent&#39;s config. The cloud answers with an AES-256-GCM stream. The tab carries ciphertext.</p>
<p>There is no fallback. If the key is missing or the signature is wrong, the call fails and the reason shows on the Internet page. An agent with no pinned key can be told to run in clear, and that is logged every time a tunnel starts.</p>
<p>The security review changed the design once. A replayed request gets a second answer under the same key, and with the plain design that reuses key and nonces, which opens the first answer. So each response now starts with a random 32-byte salt that derives its own key.</p>
<p>The tab can still see that a call happened, its size and its timing, and it can drop calls. Relays between agents on the mesh are signed, not encrypted. Agents older than this stay in clear until they are updated and have the release key, which the install script adds and <code>opm-agent upgrade</code> does not.</p>
<h2>2. The agent GUI on the cloud&#39;s own domain</h2>
<p>Each site&#39;s agent GUI now opens from the cloud console, at a path under OPM&#39;s own domain:</p>
<pre><code>&lt;cloud&gt;/app/deployments/{deploymentId}/remote/{siteSlug}/_opm/…
</code></pre>
<p>You log in to the cloud once. There is no agent key to find and no extra port for the client to open, because the site asks the cloud for work over outbound HTTPS and the cloud never calls a site. Every action is recorded on the site under the person&#39;s cloud email, and the GUI shows &quot;through the cloud&quot;.</p>
<p>This one is on by default. The client switches it off in the agent GUI on the site, and open remote sessions end at once. A remote session can&#39;t flip that switch; the call answers 403.</p>
<h2>3. Port tunnels</h2>
<p>A container port, the site&#39;s edge or a TCP port on the server can be forwarded to a port on the cloud&#39;s VM. It carries raw TCP, so a website, an API or a database client all work, and a site with no cloud route of its own can be served through a peer.</p>
<p>You add a tunnel in the console, and it does nothing there. It stays off until someone switches it on in that site&#39;s agent GUI, under Agent › Tunnels. Each site keeps its own switches.</p>
<p>Two limits, because the switch is only as good as what sits around it:</p>
<ul>
<li>The tunnel switch also works from the agent GUI opened through the cloud. So a client who wants the decision to stay on their side should turn the remote GUI off as well.</li>
<li>The public port has no login of its own. Whoever reaches it reaches the target, so the target&#39;s own auth is what stands there.</li>
</ul>
<h2>Why the switches sit on the client&#39;s side</h2>
<p>Local deployment is why these clients buy Envizom in the first place. They keep the data, and now they also decide how much of a path to it exists. If a client isn&#39;t comfortable with any of this, the same agent GUI turns it off and the deployment keeps running without it.</p>
<p>The code is at <a href="https://github.com/Panth977/on-premise-manager">github.com/Panth977/on-premise-manager</a>, Apache-2.0. The sealing is in SPEC-final §9.10, the two tunnels in §21 #118 and #125.</p>
]]></content:encoded>
</item>
<item>
  <title>A kanban board for AI coding agents: 12 stages, 6 repos</title>
  <link>https://panth.vardayinitech.in/blog/kanban-board-for-ai-coding-agents</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/kanban-board-for-ai-coding-agents</guid>
  <pubDate>Tue, 03 Nov 2026 00:00:00 GMT</pubDate>
  <description>Orch2 turns a ticket board into the control panel for coding agents: a plain script runs 12 stages, agents only write code, and every ticket gets a sandboxed copy of the product.</description>
  <category>ai</category><category>agents</category><category>devops</category><category>claude</category>
  <content:encoded><![CDATA[<p>12 board stages, 6 repos per ticket, about 5,100 lines of TypeScript, 2 days to build. The first full ticket went from Todo to merged across all 6 repos for 0.8M tokens.</p>
<p>That is Orch2, the development pipeline we run on Envizom at Oizom, where I&#39;m a Tech Lead. One change usually touches a Go backend, a Svelte frontend, a UI library and 3 driver repos. Orch2 turns a kanban board into the control panel: a plain script runs the pipeline, Claude agents only do the coding, and every ticket gets its own sandboxed copy of the whole product. The Envizom code is proprietary, so this is the design, not the source.</p>
<p>Three things stay separate. The tracker is a product. Orch2 is a client of it. The agent is what Orch2 runs.</p>
<div class="video"><iframe src="https://www.youtube-nocookie.com/embed/4tBxVxzHWNw" title="A kanban board for AI coding agents: 12 stages, 6 repos" loading="lazy" allowfullscreen allow="accelerometer; encrypted-media; picture-in-picture"></iframe></div><h2>A script, not an agent</h2>
<p>The orchestrator is ordinary TypeScript: predictable, cheap and easy to debug. An LLM starts only when there is code to write or a conflict to resolve. The board is the whole UI. There are no dashboards and no CLI for users, just stages, the thread and question cards, and orch replies in the thread with links, PRs and preview URLs. A human types one field, Src Branch. The ticket branch is the ticket key in every repo, and everything else is derived.</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>Who</th>
<th>What happens</th>
</tr>
</thead>
<tbody><tr>
<td>Todo</td>
<td>human</td>
<td>backlog, orch never acts</td>
</tr>
<tr>
<td>Pull Latest</td>
<td>script</td>
<td>branch in all 6 repos, workspace built, stack health-checked</td>
</tr>
<tr>
<td>Approved</td>
<td>script</td>
<td>agent started or resumed on the ticket branch</td>
</tr>
<tr>
<td>Working</td>
<td>agent</td>
<td>posts a plan as a task list, codes, tests against its live stack, calls <code>endWork</code></td>
</tr>
<tr>
<td>Review</td>
<td>human</td>
<td>opens the running app and the browser IDE</td>
</tr>
<tr>
<td>Git Push</td>
<td>script</td>
<td>commit, merge base, build check, push, one PR per changed repo</td>
</tr>
<tr>
<td>Preview</td>
<td>script</td>
<td>production build, HTTPS link posted</td>
</tr>
<tr>
<td>Confirm</td>
<td>human</td>
<td>the lead checks the preview</td>
</tr>
<tr>
<td>Git Merge</td>
<td>script</td>
<td>every PR checked first, then merged libraries-first</td>
</tr>
<tr>
<td>Completed</td>
<td></td>
<td>done</td>
</tr>
<tr>
<td>Cancel</td>
<td>human</td>
<td>Yes/No card, stops the agent, closes the PRs</td>
</tr>
<tr>
<td>Trashed</td>
<td></td>
<td>branch, workspace and preview deleted</td>
</tr>
</tbody></table>
<p>Every agent turn posts a short receipt and adds its token use to a board aggregate, so cost shows per ticket on the card and per day in analytics. The aggregate is stored in millions because 798,400 on a card was unreadable. It now says 0.8.</p>
<p>Each developer runs their own orch, with its own board agent and token, so assigning that agent to a ticket hands the ticket to that machine. The same codebase has a <code>preview</code> mode that runs once, on the dev server, for production previews.</p>
<h2>A copy of the product per ticket</h2>
<p>A workspace is 6 repo clones, its own data and its own stack on its own block of ports. Git uses a bare mirror of each repo and clones with <code>--reference --dissociate</code>, so a checkout takes seconds and owns its objects. GitHub auth goes per command as an HTTP header, so no credential is written to disk in a workspace.</p>
<p>One front router on a single port routes by hostname: <code>&lt;ticket&gt;.localhost</code> for the app and <code>code-&lt;ticket&gt;.localhost</code> for <a href="https://github.com/Panth977/code-shell">code-shell</a>, the browser IDE. Browsers resolve <code>*.localhost</code> themselves, so a new ticket needs no DNS record and no proxy reload.</p>
<p>Data is real and safely copied. SQLite comes through a token-gated read-only file server as a <code>VACUUM INTO</code> snapshot, because copying a live WAL database gives a corrupt file. Downloads are checked by magic bytes, so an HTML error page can never be saved as a database.</p>
<h2>Five layers around the agent</h2>
<p>A ticket agent can run commands, edit, build and test. It can&#39;t push, reach secrets or touch anything outside its ticket. A gap in one layer is caught by the next:</p>
<ol>
<li><strong>Scrubbed environment.</strong> Every secret removed, for the agent, the workspace stack and the IDE.</li>
<li><strong>OS sandbox on every shell command.</strong> Seatbelt on macOS, bubblewrap on Linux. Writes only in the workspace and build caches, network limited to localhost, package registries and GitHub.</li>
<li><strong>A guard on every tool call.</strong> The same policy twice, in the SDK permission callback and a PreToolUse hook. It blocks <code>git push</code>, branch switches, <code>reset --hard</code>, <code>ssh</code> and piping downloads into a shell, orch-managed files are read-only, and every denial returns the reason.</li>
<li><strong>A narrow tool surface.</strong> Only orch&#39;s own MCP server: ticket and thread, plan, stack control, <code>endWork</code>. No connectors, and no web fetch because it could leak ticket text.</li>
<li><strong>Gates.</strong> <code>endWork</code> is refused until the plan is finished, the stack is healthy and the merge is clean. Orch commits and pushes only after a human moves the card to Git Push.</li>
</ol>
<h2>What production taught us</h2>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td>160k tokens lost by one agent fighting a failed Go module download</td>
<td>network build failures go to a human; agents get only code problems</td>
</tr>
<tr>
<td>Corporate TLS inspection broke Go proxy and GitHub calls at random</td>
<td>quiet retries at 1, 3 and 9 minutes before anything is posted</td>
</tr>
<tr>
<td>Fast clones failed with &quot;unable to parse commit&quot; on new git versions</td>
<td>commit-graph off on clone and mirror fetch</td>
</tr>
<tr>
<td>A production build ran out of memory and hung a swapless 15 GB server</td>
<td>one preview at a time, container memory limit, heap cap, free-memory check</td>
</tr>
<tr>
<td>A preview stack died after a container restart</td>
<td>10-minute watchdog; startup reconciles every ticket</td>
</tr>
</tbody></table>
<p>The first row decided a design rule: infra failures go to a person, never an agent, because an agent can&#39;t fix a firewall and will burn tokens trying.</p>
<p>What in your pipeline still lets an agent spend tokens on a problem it can&#39;t solve?</p>
]]></content:encoded>
</item>
<item>
  <title>Release-based planning: only the release gets a deadline</title>
  <link>https://panth.vardayinitech.in/blog/release-based-planning-not-feature-deadlines</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/release-based-planning-not-feature-deadlines</guid>
  <pubDate>Mon, 02 Nov 2026 00:00:00 GMT</pubDate>
  <description>For 6 months as a Tech Lead I couldn&apos;t say no, and every deadline slipped. My answer was two shifts: plan releases, not features, and make the product document the source of truth.</description>
  <category>engineering-management</category><category>release-management</category><category>process</category><category>leadership</category>
  <content:encoded><![CDATA[<p>For my first 6 months as a Tech Lead I did not know how to say no, and every deadline got pushed. My answer was two shifts: we plan releases instead of features, and the product document replaced the codebase as the source of truth.</p>
<p>I&#39;m Panth Patel, Tech Lead on Envizom at Oizom. This post is about process, not code.</p>
<div class="video"><iframe src="https://www.youtube-nocookie.com/embed/SkOCkXavxuQ" title="Release-based planning: only the release gets a deadline" loading="lazy" allowfullscreen allow="accelerometer; encrypted-media; picture-in-picture"></iframe></div><h2>What the job turned out to be</h2>
<p>I started as a backend developer. I could debug anything in the stack, and I was comfortable helping with product design and the roadmap. None of that prepared me for a product that sells to clients.</p>
<p>Some features were wanted right now or the client wouldn&#39;t buy. Others were insistent suggestions that could sit in the backlog for months before anyone asked for an update. There was no cap on how many came in. As a dev you think everything is doable, until you realise you are no longer a proof of concept and people expect professional experience.</p>
<p>And I kept running into one question. If you expect no bugs and a stable release, how can you commit to a client on a 1-week timeline?</p>
<p>We were selling almost everything, about 80-90% of what clients asked for, and the team was praised for it many times. But every deadline slipped, and every release took new features at the last moment. Clients expected a stable system while we still acted like a startup. To scale, Envizom had to become a separate product, with client customisation in a proper framework instead of production code changes.</p>
<p>So I spent 2 months reading how professionals run this: how they scale, how they set KRAs and KPIs, how big tech manages a codebase with frequent releases. Some of it was too much for us and some too little. What we ended up with is good enough, and it works like a machine: each part has one job.</p>
<h2>Shift 1: the document changes first</h2>
<p>Every feature now starts as a 5-10 minute group discussion, without waiting for a standup. It defines the exact change to the product document before anyone implements anything. Product development happens there, and execution doesn&#39;t. If the feature needs research, a separate R&amp;D task starts and blocks it.</p>
<p>Then the feature doc goes to three people at once:</p>
<table>
<thead>
<tr>
<th>Role</th>
<th>Before</th>
<th>Now</th>
</tr>
</thead>
<tbody><tr>
<td>Lead</td>
<td>came in only if needed</td>
<td>estimates from the doc</td>
</tr>
<tr>
<td>Dev</td>
<td>built it, then explained it to the product owner and QA/QC</td>
<td>implements and tests their own work</td>
</tr>
<tr>
<td>QA/QC</td>
<td>driven by the dev</td>
<td>writes cases from the doc, runs them, attaches a report</td>
</tr>
</tbody></table>
<p>The last row is the one I care about most. QA/QC reads only the latest feature doc, so a bug gets caught the way a client would hit it. Before this, the product truth always ended up being the code.</p>
<p>Each task also attaches two short videos: one explaining the product, one explaining the development interfaces and contract. The product owner and dev lead confirm the original clarifications were met, and only then does it go to QA/QC.</p>
<h2>Shift 2: features have no deadlines</h2>
<p>Nobody asks whether a feature is &quot;immediate&quot; any more, and nothing is &quot;4 days&quot; or &quot;1 month&quot;. A feature goes into a release, and no work happens until a release is defined. A feature can join a release only when no R&amp;D is left, just implementation. Only the release has a deadline and an estimated dev time.</p>
<p>The calendar has rules:</p>
<ul>
<li>At least 2 days of code freeze per release.</li>
<li>1 extra QA/QC day after the release.</li>
<li>Releases happen on working days, with at least 2 working days ahead and no consecutive holidays.</li>
</ul>
<p>Git follows the release too. Each release has a dev branch and a prod branch. Every feature and bug-fix branch comes off the release&#39;s dev branch and merges back to it. The prod branch has two environments: demo, recently cloned from the prod DB and running the upcoming release, and prod itself, updated on release date.</p>
<p>The comms come first as well. Change logs, product updates and press releases are defined at the start of release development and go out to client-facing colleagues, devs, QA/QC and leads.</p>
<p>Where in your team does a feature get its deadline: when someone asks for it, or when it joins a release?</p>
]]></content:encoded>
</item>
<item>
  <title>944 lines that turn a ticket into a headless Claude Code session</title>
  <link>https://panth.vardayinitech.in/blog/ticket-to-headless-claude-code-session</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/ticket-to-headless-claude-code-session</guid>
  <pubDate>Mon, 26 Oct 2026 00:00:00 GMT</pubDate>
  <description>I hand work to Claude Code through tickets, not a terminal. A 944-line orchestrator with zero dependencies pulls each ticket, runs one headless session per ticket, ends the turn on a question and resumes it on the answer. It is open source (MIT), together with the tracker.</description>
  <category>claude-code</category><category>ai-agents</category><category>open-source</category><category>firebase</category>
  <content:encoded><![CDATA[<p>I hand work to Claude Code through tickets, not a terminal. A 944-line orchestrator with zero dependencies turns each ticket into its own headless Claude Code session, and on 27 September I open-sourced it together with the tracker it works in: <a href="https://github.com/Panth977/ticket-tracker">ticket-tracker</a>, MIT.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. I dropped out before the AI wave, because I figured the engineering was in solving problems in the real world, and my college made me choose between attendance and real-world applications. This post is about how the orchestrator works and why it is shaped the way it is.</p>
<h2>Why tickets and not a terminal</h2>
<p>I wanted to give my VM work from my phone, while travelling or waiting in a queue, with no laptop. The first version was a terminal in an app. It grew into a room full of agents, and three things went wrong:</p>
<ol>
<li>I got attached to one long-running agent, and starting fresh ones was a chore.</li>
<li>The app was a door into the VM. If someone broke in, I would spend a long time cleaning up.</li>
<li>When the VM lost internet, I couldn&#39;t even queue a prompt.</li>
</ol>
<p>Tickets fixed all three. Every ticket gets its own session, so there is no single agent to get attached to. The orchestrator runs on the machine and pulls work from the tracker, so nothing has to connect into that machine. The tracker is hosted apart from it, so I can raise tickets while the VM is offline, and my VM and my PC can take work from the same board.</p>
<h2>The loop</h2>
<p>An agent is a board member: a profile, a system prompt and a token that acts as that agent on one board. Assign a ticket to it and move the ticket to To do. From there:</p>
<pre><code>To do  ──orch claims──▶ In progress ──agent reports &quot;review&quot;──▶ Review ──owner──▶ Done
                            │  ▲
           agent asks a     │  │ the answer (form or a comment) resumes
           question ────────▼  │ the same session
                          waiting
</code></pre>
<p>The orchestrator claims the ticket and starts <code>claude -p</code> in the repo the workspace points at, with the tracker&#39;s MCP tools. The agent reads the ticket and its thread, then publishes its plan as a task list on the ticket and marks each item as it goes.</p>
<p>When it needs a decision, it calls <code>ask_question</code>, which puts a form card in the thread, and ends its turn with <code>status: &quot;waiting&quot;</code>. It does not poll. The answer, or any comment in the thread, starts the next turn with <code>--resume</code> on the same session, so the agent picks up with its whole history. Moving the ticket back to To do resumes it as rework. Unassigning it, or moving it anywhere else while the agent runs, stops the agent.</p>
<p>The brief every agent gets says it in two lines:</p>
<blockquote>
<p>You run headless in [the repo] and nobody is watching your terminal. The owner only sees the ticket, so everything you want them to see goes on the ticket.</p>
</blockquote>
<h2>The orchestrator owns the ticket&#39;s state</h2>
<p>The agent gets the tracker&#39;s MCP tools, minus the ones that decide where the ticket stands:</p>
<pre><code class="language-js">// What only the orch may do: the stage, the heartbeat, the inbox, assignment.
const DISALLOWED = [&#39;move_ticket&#39;, &#39;update_ticket&#39;, &#39;assign_ticket&#39;, &#39;create_ticket&#39;, &#39;heartbeat&#39;, &#39;get_events&#39;, &#39;ack_events&#39;]
  .map((t) =&gt; `mcp__tm__${t}`).join(&#39;,&#39;);
</code></pre>
<p>The agent does the work and reports <code>review</code>, <code>waiting</code> or <code>blocked</code>. The orchestrator moves the ticket, beats, reads the event inbox and posts the receipt. So a ticket only reaches Review after the orchestrator has seen the agent report it done.</p>
<p>The receipt is one message per turn with what the turn cost. Claude Code&#39;s <code>total_cost_usd</code> is cumulative across <code>--resume</code>, so the orchestrator stores the last total and posts the difference. Because the agent pushes the cost of each turn, you can see where the money actually went — which ticket, which turn — instead of one number at the end of the month. One ticket of mine reached $402 over nine runs before the app showed any of it; the full story of that fix is in <a href="/blog/cost-per-turn-headless-claude-code">$402 on one ticket</a>.</p>
<h2>Health from a heartbeat</h2>
<p>The orchestrator beats once a minute while an agent runs, and every fifth beat it re-reads the task list, so the ticket&#39;s dot shows progress like <code>3/12 · Running tests</code>. The board never stores &quot;this agent is dead&quot;. It derives health from the last status and the time since the last beat, in one function, recomputed every 15 seconds:</p>
<table>
<thead>
<tr>
<th>Health</th>
<th>When</th>
</tr>
</thead>
<tbody><tr>
<td>working</td>
<td>state is working and the last beat is under 75 s old</td>
</tr>
<tr>
<td>stale</td>
<td>state is working and no beat for 75 s: the orchestrator may have died</td>
</tr>
<tr>
<td>idle</td>
<td>waiting for an answer, or stopped</td>
</tr>
<tr>
<td>done</td>
<td>finished, ready for review</td>
</tr>
<tr>
<td>error</td>
<td>stopped with an error, for example a timeout</td>
</tr>
<tr>
<td>none</td>
<td>no status yet</td>
</tr>
</tbody></table>
<p>75 seconds is one missed beat plus a little slack. Only &quot;working&quot; can go stale: idle, done and error are statements that stay true until the next beat. After five minutes of silence the agent&#39;s owner gets one notification per silence.</p>
<h2>One command layer</h2>
<p>The app, the REST API (<code>/v1</code>, OpenAPI 3.1) and the MCP server (<code>/mcp</code>) all run the same commands. Nothing an agent can do is different from what a person can do, and every change is attributed, for example &quot;Owner via Claude (MCP)&quot;. Tokens are scoped to one board and act either as you or as one of your agents.</p>
<h2>What running it takes</h2>
<p>Everything runs on one Firebase project: Firestore, Functions, Storage, Realtime Database, Auth and Hosting. &quot;Self-hosted&quot; here means your own Firebase project on the Blaze plan, not a Docker image. Locally, <code>pnpm dev</code> starts the emulators, seeds demo data and serves the app (Node 22, pnpm 9, Java 21 and the Firebase CLI).</p>
<p>Firebase, because the tracker had to live apart from the machines doing the work: if the VM is offline or the agents are down, I can still raise tickets. From there the app does live reads straight from Firestore, Auth and push come with it, and the orchestrator wakes on a Realtime Database revision stream, so when an agent comes back it picks up where it left off.</p>
<p>A workspace is a folder with <code>workspace.json</code> (repo, board, agent, how many agents at once), a <code>.env</code> with a Worker token for that agent, and <code>brief.md</code> with the project&#39;s rules:</p>
<pre><code class="language-sh">node workspaces/ws.mjs new myproject --repo ~/src/myproject --board MYP --agent &quot;Claude MyProject&quot;
# mint a Worker token for that agent in the app (Account › Tokens), put it in workspaces/myproject/.env
node workspaces/ws.mjs up
node workspaces/ws.mjs status
</code></pre>
<p>Agents run unattended in your working tree, with <code>permissionMode</code> from <code>workspace.json</code>: <code>acceptEdits</code> in the template, <code>bypassPermissions</code> only if you trust the brief and the repo. Read <code>workspaces/lib/brief.md</code> before you enable one.</p>
<h2>What is not in it</h2>
<p>At work I&#39;ve written about per-ticket stacks: a branch in every repo, its own services, its own copy of the dev database and a preview URL per ticket. None of that is in this repo. That code is specific to Envizom and proprietary. Here the agent works in the repo you point it at.</p>
<p>The repo is <a href="https://github.com/Panth977/ticket-tracker">github.com/Panth977/ticket-tracker</a>; <code>workspaces/README.md</code> has the loop.</p>
<p>If you run Claude Code headless, where does its question go when it needs a decision, and who answers it?</p>
]]></content:encoded>
</item>
<item>
  <title>Replacing Node-RED with a 10 MB browser editor and a start.sh</title>
  <link>https://panth.vardayinitech.in/blog/codeshell-replaces-node-red</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/codeshell-replaces-node-red</guid>
  <pubDate>Sun, 25 Oct 2026 00:00:00 GMT</pubDate>
  <description>Every on-premise Envizom deployment needs a few client-specific API integrations. Node-RED made a five-call Promise.all a research project, and a per-client repo broke the day a client cut the server off the internet. codeshell is the replacement: one Go binary in a container, an editor and terminals in the browser, and a start.sh that brings the integration back on every restart.</description>
  <category>docker</category><category>go</category><category>svelte</category><category>integrations</category><category>open-source</category>
  <content:encoded><![CDATA[<p>A client disconnected our server from the internet for security, and the integration for that client still had to be written on it.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom, where we build Envizom, an air quality platform that many clients run on their own servers. Every one of those deployments needs a bit of custom work: pull data from somewhere, or expose an API in a schema the client gives us for a public display. That work can&#39;t go into our production code. This post is about where it lives now: <a href="https://github.com/Panth977/code-shell">codeshell</a>, a small browser workspace I built and open-sourced under Apache-2.0.</p>
<div class="video"><iframe src="https://www.youtube-nocookie.com/embed/8Ghi-_7u2I0" title="codeshell: replacing Node-RED with a 10 MB browser editor and a start.sh" loading="lazy" allowfullscreen allow="accelerometer; encrypted-media; picture-in-picture"></iframe></div><h2>Node-RED first</h2>
<p>Node-RED was the default for these integrations before I joined. It is fragile for this kind of work:</p>
<ul>
<li>An async for loop is hard to write.</li>
<li>Five API calls with something equivalent to <code>Promise.all</code> is close to rocket science.</li>
<li>Debugging is the worst part.</li>
<li>If there is a JavaScript library for the job but no Node-RED node in the marketplace, you spend five hours figuring out how to set the library up.</li>
</ul>
<p>These are short programs. Plain code is the right tool for them.</p>
<h2>Then one repo per deployment</h2>
<p>The next idea: each deployment gets its own codebase, built into one Docker container that holds all of that server&#39;s customisations.</p>
<p>It works until the client disconnects the server from the internet. Then the integration still has to be written, so you open a shell in the container, edit the code with <code>vi</code>, and hope nobody pulls the latest image before you have copied the same code back into the repo.</p>
<h2>What I actually needed</h2>
<p>A VS Code-like editor in the browser, on the server, with the code directory mounted from outside so it survives a restart. If the code lives in one volume, and the volume is backed up, the integration doesn&#39;t need a repo at all.</p>
<p>VS Code in the browser is heavy for this, because the language servers and compilers run on the server. I looked for a light editor and didn&#39;t find one, so I wrote codeshell.</p>
<h2>What codeshell is</h2>
<table>
<thead>
<tr>
<th>Part</th>
<th>What</th>
</tr>
</thead>
<tbody><tr>
<td>Backend</td>
<td>Go, one ~10 MB static binary with the UI embedded. It serves plain files and PTYs. No language servers, no compilers.</td>
</tr>
<tr>
<td>Frontend</td>
<td>Svelte 5, CodeMirror 6 for the editor, xterm.js for terminals. Syntax modes load per file type.</td>
</tr>
<tr>
<td><code>/code</code></td>
<td>The one required volume. Root of the file tree, working directory of every terminal.</td>
</tr>
<tr>
<td><code>/code/start.sh</code></td>
<td>Runs on every container start, restart and recreate.</td>
</tr>
<tr>
<td>Size</td>
<td>about 1,750 lines of Go and 3,700 of Svelte and TypeScript</td>
</tr>
</tbody></table>
<p>Running one integration is one container:</p>
<pre><code class="language-sh">docker run -d --name myapp \
  -p 8080:8080 -p 3000:3000 \
  -e CODESHELL_PASSWORD=change-me \
  -v myapp-code:/code -v myapp-state:/var/lib/codeshell \
  ghcr.io/panth977/code-shell:latest
</code></pre>
<p>Port 8080 is the editor. Port 3000 is whatever the integration listens on.</p>
<h2>start.sh is the deploy</h2>
<p>Terminals are temporary on purpose. Anything you install or start from a terminal is gone after a restart. What has to come back goes in <code>start.sh</code>:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -e
command -v node &gt;/dev/null || (apt-get update &amp;&amp; apt-get install -y nodejs npm)
npm ci
exec node index.js
</code></pre>
<p>The image is Debian slim, so <code>start.sh</code> can <code>apt-get install</code> whatever runtime the integration needs. On the first run it installs Node and the modules. On later runs <code>node_modules</code> is already in the volume, and mounting <code>/root</code> and <code>/opt</code> as volumes too keeps package caches and runtimes, so restarts get faster.</p>
<p>codeshell supervises the script. Its output shows as a <code>start.sh</code> entry in the terminal panel, next to your own terminals, with Restart and Stop buttons, and its state is in the top bar: running, stopped, exited, waiting or missing. The restart policy is <code>on-failure</code> by default, and restarts back off from 1 second, doubling, to a cap of 60 seconds. On a fresh volume it writes a commented <code>start.sh</code> template with a Node and a Python example.</p>
<p>Terminals keep their scrollback on the server, so a page reload or a dropped connection doesn&#39;t lose them.</p>
<h2>Where it sits</h2>
<p>The other half of our on-premise work is <a href="https://panth.vardayinitech.in/blog/on-prem-manager-docker-deployments">On-Prem Manager</a>, which deploys and updates the containers on each client server and snapshots their volumes. With codeshell, a client&#39;s integration is one container and one volume. On-Prem Manager can persist that volume&#39;s content to its cloud, where it is versioned and reviewed, so the integration needs no repo of its own.</p>
<h2>What it doesn&#39;t try to be</h2>
<ul>
<li>Single user. One login, an HMAC-signed session cookie, and repeated failed logins from one IP are locked out for longer each time.</li>
<li>Anyone who signs in has a root shell in the container. Put it behind HTTPS and use a strong password. The password and the session secret are removed from the environment of terminals and <code>start.sh</code>.</li>
<li>No IntelliSense. It&#39;s an editor with syntax highlighting, quick open, split groups, find and replace, and a terminal.</li>
</ul>
<p>The repo is <a href="https://github.com/Panth977/code-shell">github.com/Panth977/code-shell</a>. Images are published for <code>linux/amd64</code> and <code>linux/arm64</code>.</p>
<p>Where do your per-client integrations live today, and what happens to them when the client&#39;s server goes offline?</p>
]]></content:encoded>
</item>
<item>
  <title>90/10: what we took away from our AI coding agent, and why</title>
  <link>https://panth.vardayinitech.in/blog/ninety-ten-what-we-took-away-from-the-agent</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/ninety-ten-what-we-took-away-from-the-agent</guid>
  <pubDate>Sat, 24 Oct 2026 00:00:00 GMT</pubDate>
  <description>Our first agent pipeline at Oizom let the coding agent run git and deploy steps too. It was slower, cost more, and when it failed it failed differently each time. This is the full pipeline as it runs now, column by column: the ten jobs scripts own, what the agent still does, what stays with people, what it costs, and what broke on the scripted side.</description>
  <category>ai-agents</category><category>devops</category><category>automation</category><category>ci-cd</category><category>architecture</category>
  <content:encoded><![CDATA[<p>The first version of our agent pipeline let the coding agent run the git and deploy steps. It didn&#39;t fail often, but when it did, it failed in a different way each time, and I couldn&#39;t debug that. Today roughly 90% of the pipeline is plain scripted code, and the agent does one job.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom, where Envizom is our environmental monitoring platform. Two days ago I wrote <a href="/blog/agent-pipeline-90-percent-scripts">the short version</a>: the eight steps that run after a developer drags a ticket to Git Push. This is the long one. It follows one ticket through every column of the board and goes through each job we took off the agent, with the reason, then what it costs and what broke.</p>
<h2>The first version</h2>
<p>Before agents, our problems were ordinary ones. Envizom is several repos: hardware drivers, a simulator, a backend and a frontend. A new developer needed days to get a working local setup. Releases meant keeping track of which dev database, prod database, backend and frontend were pointed at each other, and as time went on I was the only person who knew every combination.</p>
<p>Coding agents made my developers faster. With an agent, one developer can keep three tasks in flight. The mistakes then moved out of the code and into the branches around it. A change landed on the wrong branch, or a fix only worked on one machine. I wrote about that in <a href="/blog/one-stack-per-ticket">One stack per ticket</a>.</p>
<p>In the first version of the pipeline, the agent did more of the operational steps. It did the engineering, and it also ran git and the deploy. Compared with what we run now, it was slower and more expensive, and it was worse at the tasks that need to be boring.</p>
<p>The failures taught me the most. A script that fails the same way twice is a bug you can fix: you read the log, find the line and change it. An agent that merges a little differently on each ticket gives you nothing to fix. Each failure is a new story, and the next run will tell another one.</p>
<p>The split we ended up with follows one line. If a step is the same on every ticket, it is a script. The agent keeps the engineering.</p>
<h2>One ticket, end to end</h2>
<p>The board has seven columns. One of them has the agent in it.</p>
<table>
<thead>
<tr>
<th>Column</th>
<th>Who acts</th>
<th>What happens</th>
</tr>
</thead>
<tbody><tr>
<td>Feedback</td>
<td>developer</td>
<td>writes what they want, in plain English. That is the whole spec</td>
</tr>
<tr>
<td>Approved</td>
<td>scripts</td>
<td>dragging the ticket here is the handoff. The orchestrator provisions the ticket&#39;s stack</td>
</tr>
<tr>
<td>Working</td>
<td>agent</td>
<td>does the engineering work inside that stack, and talks only through the ticket thread</td>
</tr>
<tr>
<td>QAQC</td>
<td>developer</td>
<td>opens the preview the agent posted, tests it, replies in the thread or moves it on</td>
</tr>
<tr>
<td>Git Push</td>
<td>scripts</td>
<td>commit, merge dev in, open and merge a pull request per repo, post the links</td>
</tr>
<tr>
<td>VM Update</td>
<td>scripts</td>
<td>wait for CI, roll the new images onto the dev VM, health checks, rollback if unhealthy</td>
</tr>
<tr>
<td>Completed</td>
<td>scripts</td>
<td>the developer gets a notification, and the ticket&#39;s stack is torn down</td>
</tr>
</tbody></table>
<p>Each developer has their own devbox on our dev VM: VS Code in the browser, with our orchestrator, orch, inside it. Every developer is paired with their own agent, and their orch only picks up tickets assigned to that agent. The board is shared. The machines and the agents are not.</p>
<p>Here is a real ticket from the walkthrough video. I asked for a camera layout change on a device page. The agent built it in the ticket&#39;s stack and posted the preview. I replied in the thread: &quot;also handle more than 4 cameras with a horizontal scroll, and set up 8 test cameras on this device&quot;. The ticket went back to the agent, which picked up with the context it already had, did both, and created the 8 test cameras in the ticket&#39;s own copy of the database. I tested the new preview and dragged the ticket to Git Push. After that, two notifications, and the change was live on dev.</p>
<p>The person in that story touched the ticket three times: to write it, to reply to a preview, and to drag it. The agent had one column. Scripts ran the other four.</p>
<h2>The ten jobs scripts own</h2>
<p>This is the list no agent touches: watching the board, provisioning the stack, copying the database, issuing certificates, driving git, opening pull requests, waiting on CI, deploying, health checks and rollback. They fall into two groups, before the agent starts and after a person says yes.</p>
<h3>Before the agent starts</h3>
<p><strong>Watching the board.</strong> Orch watches for tickets dragged to Approved and assigned to its agent. Nothing here needs any thought. A ticket is either in the column or it isn&#39;t, and a model would add cost and nothing else.</p>
<p><strong>Provisioning the stack.</strong> Every approved ticket gets a fresh branch in every repo, named after the ticket, plus its own backend, drivers, simulator and frontend. This is the step everything else depends on, so it has to be the same every time. If the stack came out a little different per ticket, nothing built on top of it could be trusted.</p>
<p><strong>Copying the database.</strong> Each ticket gets a copy of the dev database, so it can change data freely. This is the safety decision in the whole design. An agent that runs a bad migration or deletes the wrong rows does it to the ticket&#39;s copy. Nobody else&#39;s work is in that box, so nobody else&#39;s work is touched. A step that sets up the safety net shouldn&#39;t be done by the thing the net is for.</p>
<p>In Envizom V3, which we are rebuilding from scratch, the copy is cheap. Every store has a dev driver. Postgres, S3, the time-series database and Redis all become SQLite in dev, and MQTT becomes a service built into the Go backend. SQLite runs inside the Go process, so copying a database means copying a few files. That change made our sandboxes about 10x cheaper and faster than sandboxes with full databases.</p>
<p><strong>Issuing certificates.</strong> Every ticket has a private preview URL with an HTTPS certificate issued on demand. That URL is what the developer tests in QAQC, so it has to exist and work before the agent posts it.</p>
<h3>After a person says yes</h3>
<p>When the developer drags a ticket to Git Push, no agent is involved from that moment on. Orch runs eight steps:</p>
<pre><code>Git Push
  1  commit the work on the ticket branches
  2  merge the current dev branch into them
  3  open a pull request in each affected repo
  4  merge the pull requests
  5  post the PR and commit links into the ticket thread
VM Update
  6  wait for CI
  7  roll the new images onto the dev VM
  8  run health checks against the VM
       unhealthy -&gt; roll back to the previous images, revert the merges,
                    tell the thread what happened
       healthy   -&gt; Completed, notify the developer, tear the stack down
</code></pre>
<p><strong>Driving git and opening pull requests.</strong> One of our goals from the start was that using agents would not weaken the normal process. Every feature still gets its own branch, commits, pull request and review. That only holds if those steps happen the same way on every ticket. An agent deciding how to merge is not a feature. A script that always merges the same way is.</p>
<p><strong>Posting the links.</strong> Step 5 looks small, but it is how the thread becomes the audit trail. The PR and commit links land next to the ask, the previews and the feedback, so one ticket holds the whole story of one change.</p>
<p><strong>Waiting on CI.</strong> Waiting is the clearest case of a job a model shouldn&#39;t hold. Nothing gets decided while it waits, and an agent session kept alive just to wait is still a session.</p>
<p><strong>Deploying, health checks and rollback.</strong> These are the steps where sameness matters most. If the VM is unhealthy after the deploy, orch rolls it back to the previous images, reverts the merges and tells the thread, without anyone asking. A rollback that improvises is the last thing you want on the day something breaks. A scripted one runs the way it ran on every ticket before.</p>
<p>All of it stops at the dev environment. Auto-merge, the VM update and the rollback apply to dev only. Production goes through a separate path, where people make the call on a slower cadence. That is on purpose. We don&#39;t want automation deciding what reaches production, and we don&#39;t want a fast feature list to become the goal.</p>
<h2>Taking away &quot;I&#39;m done&quot;</h2>
<p>The same split shows up in <a href="https://github.com/Panth977/ticket-tracker">ticket-tracker</a>, the board I open-sourced, where a small orchestrator turns each ticket into a headless Claude Code session. (The per-ticket stack scripts are not in it. They are Envizom-specific code.) It takes a few more jobs off the agent, ones that are easy to miss because they don&#39;t look like jobs.</p>
<p><strong>Deciding it is finished.</strong> The agent never moves its own ticket. Its last message is structured output, enforced with <code>--json-schema</code>:</p>
<pre><code class="language-js">const OUTCOME_SCHEMA = {
  type: &#39;object&#39;,
  properties: {
    status: { type: &#39;string&#39;, enum: [&#39;review&#39;, &#39;waiting&#39;, &#39;blocked&#39;] },
    summary: { type: &#39;string&#39; },
    question_id: { type: &#39;string&#39; },
  },
  required: [&#39;status&#39;, &#39;summary&#39;],
};
</code></pre>
<p>The orchestrator reads it. <code>review</code> moves the ticket to Review. <code>waiting</code> means a person is needed, so nothing runs until someone answers. <code>blocked</code> usually means the turn ran out of room, so the orchestrator resumes it by itself, up to 3 times, and then says on the ticket that it has stopped. The agent reports. The script decides what that means for the board.</p>
<p><strong>Saying it is alive.</strong> The heartbeat comes from the orchestrator, not the agent: one beat every 60 seconds while the agent works. A crashed process can&#39;t report that it crashed, so health is worked out from the age of the last beat. The agent&#39;s part is its task list, which the orchestrator reads for the status message. More in <a href="/blog/agent-heartbeat-derived-health">Agent health from a 60-second heartbeat</a>.</p>
<p><strong>Working out what a turn cost.</strong> When a run ends, the orchestrator reads Claude Code&#39;s result line and posts the turn&#39;s receipt on the ticket. The app&#39;s comment box never posts one. Each receipt goes out with an idempotency key made from the ticket and the run number, so a retried request doesn&#39;t count a turn twice.</p>
<p><strong>Waiting for an answer.</strong> When the agent needs a decision, it asks in the ticket and ends its turn. It is told not to poll. My answer starts the same session again with <code>--resume</code>. <a href="/blog/questions-end-the-turn">Headless Claude Code asks, exits, then resumes on the answer</a> has the details.</p>
<p>Any of these could be left to the agent. Each one is the same on every ticket, so none of them is.</p>
<h2>What the agent still does</h2>
<p>The engineering. It reads the ticket, works in the ticket&#39;s own repos and services, changes the code, and creates the test data it needs in the ticket&#39;s database copy, as in the 8 test cameras. When the developer replies with feedback, it picks up with the same context it had before, without re-reading the repo to find out where it was.</p>
<p>It also asks. A real decision that belongs to the owner, such as scope, design, or anything destructive or outward-facing, becomes a question in the ticket rather than a guess.</p>
<p>That is the 10%. It is the only part of the pipeline where the right answer changes from ticket to ticket, so it is the only part where a model is worth what it costs.</p>
<h2>What stays with people</h2>
<p><strong>Writing the ticket.</strong> The thread is the only way to talk to the agent, so every prompt is stored with the work it produced. I read how each person on the team asks for things and coach them on it. That has done more than any prompting guide. See <a href="/blog/prompts-stored-in-the-ticket">Our coding agent prompts live in the ticket</a>.</p>
<p><strong>QA.</strong> The agent posts a preview and moves the ticket to QAQC, and that is as far as it goes. The developer who asked tests the preview, because they are the only one who knows what they meant. An agent grading its own work would remove the one review that matters most. Every step after QA is built to be boring and repeatable, and none of them asks whether the change was the right change. QA is where that question gets asked, so it is the one place we didn&#39;t automate. More in <a href="/blog/qa-stays-human">1 human check, 8 scripted steps</a>.</p>
<p><strong>The drag to Git Push.</strong> Shipping to dev is a person&#39;s action in the thread. Shipping to production is a separate, slower decision.</p>
<h2>What it costs</h2>
<table>
<thead>
<tr>
<th>What</th>
<th>Number</th>
<th>Source of the number</th>
</tr>
</thead>
<tbody><tr>
<td>One agent turn in the walkthrough video</td>
<td>$4.77</td>
<td>the receipt under the agent&#39;s reply: &quot;turn $4.77 · total $4.77&quot;</td>
</tr>
<tr>
<td>The dev VM with every devbox and ticket stack</td>
<td>about ₹4–6k a month</td>
<td>rented, one machine</td>
</tr>
<tr>
<td>Tickets it runs in parallel</td>
<td>6, comfortably</td>
<td>same VM</td>
</tr>
<tr>
<td>Sandbox cost after SQLite dev drivers</td>
<td>about 10x cheaper and faster</td>
<td>V3 rebuild</td>
</tr>
<tr>
<td>A scripted step</td>
<td>no turn receipt</td>
<td>it is not a model call</td>
</tr>
</tbody></table>
<p>The last row is the plain reason scripts are cheaper. Every agent turn posts its cost into the ticket thread, right under the answer. A scripted merge, deploy or health check posts no receipt, because it doesn&#39;t call a model. An operational step done by the agent is done inside a billed turn.</p>
<p>The receipt is a design choice too. The person who asked sees what the ask cost, where they asked it, and it changes how they write the next one.</p>
<h2>What broke on the scripted side</h2>
<p>Scripts break as well. The difference is how they break.</p>
<p><strong>The cost number was wrong.</strong> On ticket-tracker, one ticket reached $402 over nine agent runs and nothing in the app said so. The orchestrator already wrote the cost of every run to a local log, but nothing showed it where the work was. Putting the number on the ticket had a trap: Claude Code&#39;s <code>total_cost_usd</code> is cumulative across <code>--resume</code>. Run 8 made one API call with zero output tokens and reported $392.83, the same as run 7. The fix is a few lines in the orchestrator. It keeps the last total per session and posts the difference. The full fix is in <a href="/blog/cost-per-turn-headless-claude-code">$402 on one ticket</a>.</p>
<p><strong>Polling was most of the bill.</strong> Two orchestrator loops asked the tracker&#39;s API for the full ticket and event lists every 30 seconds or so. Over three days that was 8,822 + 8,815 calls, 88% of every request the API served. The fix was again in the script. Agents now hold one stream on a small revision node and fetch only the delta when it moves. That one is in <a href="/blog/wake-on-a-revision-not-polling">Polling was 88% of my API calls</a>.</p>
<p><strong>The heartbeat was on the bill twice.</strong> The first version kept each beat stream as a Firestore document: 1,440 writes per agent per day, plus a sweep that ran every minute to notice silence, 4,261 runs in three days. Beats moved to the Realtime Database, about 100 bytes each, and silence is now worked out at read time from the age of the last beat.</p>
<p>None of these needed an agent to diagnose. Each showed up in a log or a bill, and each was fixed in one place in the code. That is the trade I wanted from the split. The agent&#39;s mistakes stay inside a ticket&#39;s stack, where a bad change hit a copy. The scripts&#39; mistakes show up in a log, the same way twice, so they can be fixed.</p>
<h2>Why the boring 90% makes the 10% useful</h2>
<p>Most of what makes the pipeline worth using comes from the scripted part. Per-ticket isolation is what lets the agent work freely, and lets a ticket sit in QAQC for a week while three others ship. The thread is the audit trail because scripts post into it at every step: the preview, the cost of each turn, the PR and commit links, and the rollback if there is one. Stored prompts are reviewable because the thread is the only channel. Human QA and dev-only scope are the two places we chose not to automate, and both are easier to hold when everything around them is predictable.</p>
<h2>The order I&#39;d build it in</h2>
<p>If you&#39;re setting up something like this:</p>
<ol>
<li>Isolation first. If every task can&#39;t run in its own branch, with its own services and its own data, nothing after it is safe.</li>
<li>A handoff surface the team already uses. For us that was the ticket.</li>
<li>Script every step that is the same on every ticket: provisioning, git, CI, deploy, health, rollback, and the receipts.</li>
<li>Only then, the agent inside the box, with the smallest job you can give it.</li>
</ol>
<p><a href="/blog/ticket-is-the-interface">The ticket is the interface</a> has the walkthrough video and the first write-up of the pipeline.</p>
<p>If your team runs coding agents, which step is the agent doing today that a script would do the same way every time?</p>
<hr>
<p><em>I lead the software team at <a href="https://oizom.com">Oizom</a>, where Envizom turns data from air-quality and environmental monitors in 80+ countries into something cities and industries can act on. I dropped out before the AI wave, because I figured the engineering was in solving problems in the real world, and my college made me choose between attendance and real-world applications. Find me on <a href="https://www.linkedin.com/in/panth-patel-447a88240/">LinkedIn</a>.</em></p>
]]></content:encoded>
</item>
<item>
  <title>On-Prem Manager: one engineer per two on-prem Docker sites didn&apos;t scale</title>
  <link>https://panth.vardayinitech.in/blog/on-prem-manager-docker-deployments</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/on-prem-manager-docker-deployments</guid>
  <pubDate>Fri, 23 Oct 2026 00:00:00 GMT</pubDate>
  <description>Governments want their air quality data on their own servers, so on-premise is how Envizom sells. Docker Compose and Portainer got us to about five deployments a year with one engineer per two of them. On-Prem Manager is what I built to go past that: a cloud console with git-style commits per site, and one Go agent per server that snapshots, applies, verifies and rolls back, even with no internet.</description>
  <category>docker</category><category>on-premise</category><category>devops</category><category>go</category><category>open-source</category>
  <content:encoded><![CDATA[<p>About five on-premise deployments a year, and one dedicated engineer for every two of them. That was the shape of our setup when we realised on-premise was going to be most of our business.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom, where we build Envizom, an air quality monitoring platform. This post is about the deployment problem that came with selling it, and <a href="https://github.com/Panth977/on-premise-manager">On-Prem Manager</a> (OPM), the tool I built and open-sourced under Apache-2.0 to fix it.</p>
<div class="video"><iframe src="https://www.youtube-nocookie.com/embed/pZfftcOw_vU" title="On-Prem Manager: git-style commits for on-premise Docker servers" loading="lazy" allowfullscreen allow="accelerometer; encrypted-media; picture-in-picture"></iframe></div><h2>Why on-premise</h2>
<p>Many organisations don&#39;t want to hand their data to a cloud. Governments most of all, and air quality is first a government responsibility. So when a government picks software like Envizom, it also needs that software on its own servers, for its own security reasons.</p>
<p>The more we sold, the clearer it got: local deployment is the one real selling point for an air quality platform, and per-client customisation is what wins the client.</p>
<h2>What a deployment actually looks like</h2>
<p>The ideal case: the client gives you exactly what you asked for, and if something breaks on their side, a firewall rule or an IP config, they own it.</p>
<p>That is not what happens. One client wants a DC/DR setup. Another wants high availability. Another wants a single server. Another wants DC/DR eventually, but for now on one server, with a second one later and two more after that.</p>
<p>Then there are the integrations: pull data from one of their systems, or expose an API in a schema they hand us for a public display. None of that belongs in our production code.</p>
<h2>What we had</h2>
<p>An end-to-end setup on Docker Compose, with Portainer as the GUI.</p>
<p>After enough deployments it was clear Compose wasn&#39;t enough. Every client is different, so every install meant a lot of configuration and setup afterwards. A simple issue took hours to close, because debugging a remote server through that stack is slow.</p>
<p>That setup is fine at five deployments a year with one engineer for every two. It is the bottleneck the moment you want more.</p>
<h2>Where the idea came from</h2>
<p>Oizom started as an IoT company. It ships devices with software inside, and every device sits at a remote location where nobody can do anything by hand. For that, Oizom uses a device manager, Aikaan.</p>
<p>So: what if our deployment team had the same thing for on-premise servers?</p>
<p>What the team needs to run a deployment is a short list. Automated volume backups. A way to make a new version live. Environment and config per site. Variables, with separate secrets for each deployment. A GUI to see the containers. I wrote down every problem we hit in operations and built a tool around the list.</p>
<h2>What OPM is</h2>
<p>Two parts.</p>
<table>
<thead>
<tr>
<th>Part</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td>Cloud console</td>
<td>The catalog of images, deployment templates, variables, secrets and files. You commit a change once and review the diff per site. Self-host it on a VM (SQLite and local files) or run it on Firebase, same frontend build.</td>
</tr>
<tr>
<td>Agent</td>
<td>One Go binary per server, with its own GUI built in. It applies commits to that server&#39;s Docker, runs an embedded Caddy as the edge proxy, snapshots volumes, and rolls back.</td>
</tr>
</tbody></table>
<p>The README puts it as: like Portainer, plus a git-style commit history for every server&#39;s config.</p>
<h3>Install is one line</h3>
<p>The cloud&#39;s Setup page gives a command per server:</p>
<pre><code class="language-sh">curl -fsSL -H &quot;Authorization: Bearer opi_…&quot; &lt;apiBase&gt;/v1/install/script | sudo sh
</code></pre>
<p>It runs preflight checks, uses the Docker that is already there (20.10 or newer) or asks before installing a pinned version, downloads the agent and checks its sha256 and Ed25519 signature, writes the config, sets up a systemd service, and waits up to 60 seconds for a health check, rolling itself back if that fails. Everything it installs is pinned when the script is generated.</p>
<h3>Images without a registry</h3>
<p>You register images in the cloud&#39;s catalog, and the cloud keeps its own copy of their layers. The agent loads images from those blobs with <code>docker load</code> and never connects to a Docker registry, so it never sees a registry password. Sites in the same deployment copy layers from each other first, and only one site with internet fetches from the cloud.</p>
<h3>Commits, not proposals</h3>
<p>Templates, deployments and each site are branches of commits. The latest commit on a deployment&#39;s branch is what its sites should run. You can compare any two branches and cherry-pick between them.</p>
<p>Nothing changes on a site until a person on that site merges it, in the agent GUI. A merge runs one fixed sequence:</p>
<pre><code>LOCK -&gt; SNAPSHOT -&gt; APPLY -&gt; VERIFY -&gt; COMMIT
</code></pre>
<p>VERIFY waits for each container to pass its health check, or to run 10 seconds without a restart, within 300 seconds by default. Any failure rolls back, and a failed merge leaves no commit. The failed container is kept for 24 hours under a <code>-failed-&lt;time&gt;</code> name, with its last 200 log lines, so the post-mortem doesn&#39;t depend on someone grabbing logs in time.</p>
<p>The dangerous act isn&#39;t the merge, it&#39;s restoring data. So rollbacks and volume restores get a 60-second countdown and a typed <code>ACCEPT</code>. The spec&#39;s reasoning: &quot;the commit message + plan review are the deliberate act… restoring data is still the dangerous act&quot;.</p>
<h3>Backups by default</h3>
<p>Before every update and rollback, the agent snapshots config, image references and the volumes marked for backup, as <code>.tar.zst</code> archives. It keeps the last 10. A scheduled snapshot runs daily at 02:00 site time and keeps the last 7. A snapshot refuses to start if free disk is under 1.2 times its estimate. Snapshots download as signed <code>.opmsnap</code> files.</p>
<h3>Docker stays the source of truth</h3>
<p>People still change things in Docker directly. The agent scans every 60 seconds and on Docker events. A hand edit to an image, env, port, mount or file shows up as uncommitted drift, and merges are refused until it is committed or discarded. A site can also auto-commit drift once it has been stable for 30 seconds, authored as &quot;changed in Docker&quot;.</p>
<h3>Standalone first</h3>
<p>Every agent GUI can do everything locally with no cloud at all: create containers, change images, edit files, variables and secrets. The cloud is how you run many sites, not something a site needs to keep running.</p>
<h2>When the client cuts the internet</h2>
<p>Some clients take the server off the internet for security. OPM has three routes to the cloud, in order:</p>
<ol>
<li>The server&#39;s own internet.</li>
<li>A peer site in the same deployment that has internet, relaying.</li>
<li>The browser of the person using the agent GUI. The GUI tab long-polls the agent and runs the cloud requests from the browser, so an engineer&#39;s laptop on the client network becomes the tunnel.</li>
</ol>
<p>The agent only ever talks HTTPS on 443 to one API domain, which is one line in a client&#39;s firewall request.</p>
<p>And for a client that allows neither the agent nor the cloud, the cloud can mirror every branch to a GitHub repository. A machine checks out its site&#39;s branch, fills in <code>site.env</code> and runs <code>./deploy.sh</code>: plain <code>docker compose</code>, Caddy as the router, Portainer as the GUI, no OPM code on the box. A real-Docker test compares that output with the agent&#39;s and reports 0 differences. You lose drift detection, snapshots and rollback, which is why it&#39;s the last resort.</p>
<h2>The size of it</h2>
<p>The full specification, <code>SPEC-final.md</code>, is 894 KB, and its decision log has 116 numbered entries, each with the reason. The code is about 97,000 lines of Go, 96,000 of TypeScript and 60,000 of Svelte, with 105 Go test files and 157 TypeScript test files. There is an end-to-end harness that runs real agents against real Docker.</p>
<p>Integrations got their own tool too. They run in <a href="https://github.com/Panth977/code-shell">codeshell</a>, a small browser editor in a container whose code is one volume. OPM&#39;s &quot;Persist to cloud&quot; versions a volume&#39;s content in the cloud, the docs name &quot;client integration code&quot; as the use, so that code is reviewed and kept there instead of in a git repository. codeshell gets its own post.</p>
<h2>What I&#39;d ask you</h2>
<p>If you ship software that customers run on their own servers: what does one deployment cost you in engineer-hours, and which part of it did you automate first?</p>
]]></content:encoded>
</item>
<item>
  <title>90% of our AI coding agent pipeline is plain scripted code</title>
  <link>https://panth.vardayinitech.in/blog/agent-pipeline-90-percent-scripts</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/agent-pipeline-90-percent-scripts</guid>
  <pubDate>Thu, 22 Oct 2026 00:00:00 GMT</pubDate>
  <description>In our dev pipeline at Oizom, an AI coding agent does the engineering work inside one ticket&apos;s isolated stack. Everything around it, from provisioning to merge, deploy, health checks and rollback, is scripted. We tried giving the agent more first. It was slower, cost more, and was worse at the boring parts.</description>
  <category>ai</category><category>devops</category><category>automation</category><category>ci-cd</category>
  <content:encoded><![CDATA[<p>Roughly 90% of the pipeline that ships our code is plain scripted code. The AI coding agent does one thing: the engineering work inside a single ticket&#39;s workspace, in response to what a person wrote in that ticket.</p>
<p>We didn&#39;t start there. The first version gave the agent more of the operational steps, and this post is about why we took them back.</p>
<p>I lead the software team at Oizom, where Envizom is our environmental monitoring platform. The pipeline below is our dev pipeline for Envizom.</p>
<h2>The shape of it</h2>
<p>A developer writes what they want in a ticket, in plain English, and drags it to <strong>Approved</strong>. Our orchestrator picks it up and gives the ticket its own stack: a branch in every repo named after the ticket, its own backend, drivers, simulator and frontend, a copy of the dev database, and a private preview URL with an HTTPS certificate issued on demand.</p>
<p>The agent works inside that stack and talks to the developer only through the ticket thread. When it&#39;s done it posts the preview link and moves the ticket to <strong>QAQC</strong>. The developer tests the preview, replies in the thread, and either sends it back or drags it on.</p>
<pre><code>Approved ──► [scripts] provision stack ──► [agent] engineering work ──► QAQC
                                                    ▲                    │
                                                    └── feedback ────────┤
                                                                         ▼
Completed ◄── [scripts] commit, merge, PR, CI, deploy, health ◄── Git Push
     │                         │
     │                         └── unhealthy: roll back images, revert merges
     ▼
 stack torn down
</code></pre>
<p>One box in that diagram has the agent in it. Everything else is a script.</p>
<h2>What the scripts do</h2>
<p>Here is the list of jobs no agent touches:</p>
<ul>
<li>watching the board</li>
<li>provisioning the per-ticket stack</li>
<li>copying the database</li>
<li>issuing certificates</li>
<li>driving git</li>
<li>opening pull requests</li>
<li>waiting on CI</li>
<li>deploying</li>
<li>health checks</li>
<li>rollback</li>
</ul>
<p>The clearest example is what happens when a developer drags a ticket to <strong>Git Push</strong>. From that moment there is no agent involved. The orchestrator runs eight steps:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Step</th>
<th>Done by</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Commit the work on the ticket branches</td>
<td>script</td>
</tr>
<tr>
<td>2</td>
<td>Merge the current dev branch into them</td>
<td>script</td>
</tr>
<tr>
<td>3</td>
<td>Open a pull request in each affected repo</td>
<td>script</td>
</tr>
<tr>
<td>4</td>
<td>Merge the pull requests</td>
<td>script</td>
</tr>
<tr>
<td>5</td>
<td>Post the PR and commit links back into the ticket thread</td>
<td>script</td>
</tr>
<tr>
<td>6</td>
<td>Wait for CI</td>
<td>script</td>
</tr>
<tr>
<td>7</td>
<td>Roll the new images onto the dev VM</td>
<td>script</td>
</tr>
<tr>
<td>8</td>
<td>Run health checks against the VM</td>
<td>script</td>
</tr>
</tbody></table>
<p>If the VM isn&#39;t healthy afterwards, the orchestrator rolls it back to the previous images and reverts the merges, on its own, and then tells the thread what happened. If it is healthy, the ticket moves to <strong>Completed</strong> and the developer gets a notification. The ticket&#39;s stack is torn down after that.</p>
<h2>Why we took the steps back from the agent</h2>
<p>We tried it the other way first, with the agent doing more of the operational steps. It was slower, it was more expensive, and it was worse at exactly the tasks that need to be boring.</p>
<p>That last one is the reason that matters. Merging, deploying and rolling back should happen the same way on every ticket, and that sameness is the whole value. An agent deciding how to merge is not a feature. A script that always merges the same way is.</p>
<p>The scripted part is deterministic and tested. When it breaks, I read a log and fix it.</p>
<h2>Where the agent earns its place</h2>
<p>The agent&#39;s job is the engineering work itself. In the walkthrough video I asked for a camera layout change on a device page. The agent shipped it and posted the preview. I replied in the thread asking it to also handle more than 4 cameras with a horizontal scroll, and to set up 8 test cameras on that device. It did both, including creating the test data in the ticket&#39;s own database copy, and it picked up with the same context it had before.</p>
<p>Isolation is what lets it do that safely. Because every ticket has its own stack, an agent that does something silly to the database did it to a copy. A developer can have several tickets in flight at once, and one ticket can sit in QAQC for a week while three others ship.</p>
<p>Two more lines we drew on purpose:</p>
<ul>
<li><strong>QA is human.</strong> The developer tests the preview and decides whether the ticket moves forward. An agent grading its own work would remove the one review that matters most.</li>
<li><strong>This pipeline stops at dev.</strong> Auto-merge, rollback and VM updates all apply to the dev environment only. Production goes through a separate path where people make the call, on a slower cadence.</li>
</ul>
<h2>The order I&#39;d build it in</h2>
<p>If you&#39;re setting up something similar, this is the order we ended up with:</p>
<ol>
<li>Isolation first. If every task can&#39;t run in its own branch, its own services and its own data, nothing after it is safe.</li>
<li>A handoff surface the team already uses. For us that was the ticket.</li>
<li>Only then, the agent inside the box.</li>
</ol>
<p>And keep the box scripted. The agent should be the smallest part of the system.</p>
<p>The longer write-up, with the video walkthrough, is <a href="/blog/ticket-is-the-interface">The ticket is the interface</a>.</p>
<p>If your team runs coding agents, which step did you hand to the agent that a script should own?</p>
]]></content:encoded>
</item>
<item>
  <title>0 reads to switch views: Firestore board filters in the browser</title>
  <link>https://panth.vardayinitech.in/blog/board-views-in-the-browser</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/board-views-in-the-browser</guid>
  <pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate>
  <description>Firestore can&apos;t OR across fields and wants a composite index per filter combination, so my tracker loads a board&apos;s active tickets once and runs filter, sort, group-by and swimlanes as one pure function in the browser. Switching views costs no reads. The ceiling is about 5,000 active tickets per board.</description>
  <category>firestore</category><category>sveltekit</category><category>data-modeling</category><category>open-source</category>
  <content:encoded><![CDATA[<p>Switching between saved views on a board in my tracker costs zero Firestore reads. The board&#39;s active tickets are loaded once and kept live, and every filter, sort, group-by and swimlane is a pure function run over them in the browser.</p>
<p>The constraint that forced it: Firestore cannot OR across fields, and every combination of filters you want to query needs its own composite index. A tracker with user-defined views over custom fields would need thousands of them.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. This is from <a href="https://github.com/Panth977/ticket-tracker">ticket-tracker</a>, my open-source board. Its decisions page lists the choices that are expensive to reverse, each with the alternative that was turned down. This is D5.</p>
<h2>The decision</h2>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Chosen</strong></td>
<td>One live query per board over its active tickets. Filter, sort, group-by and swimlanes are pure functions in <code>packages/shared</code>.</td>
</tr>
<tr>
<td><strong>Rejected</strong></td>
<td>One Firestore query per view.</td>
</tr>
<tr>
<td><strong>Why</strong></td>
<td>Firestore cannot OR across fields, cannot filter on range and inequality over two fields, and needs a composite index for every combination. User-defined views with custom fields would need thousands of indexes. In the browser, switching views is instant and costs no reads.</td>
</tr>
<tr>
<td><strong>Ceiling</strong></td>
<td>About 5,000 active tickets per board, or about 5 MB.</td>
</tr>
</tbody></table>
<p>A saved view is a few lines of data. A filter is a tree of <code>and</code> / <code>or</code> groups over leaves:</p>
<pre><code class="language-ts">export type FilterGroup = { op: &#39;and&#39; | &#39;or&#39;; children: FilterNode[] };
export type FilterLeaf = { field: string; cmp: Cmp; value?: unknown };
export type FilterNode = FilterGroup | FilterLeaf;
</code></pre>
<p>A leaf can compare any built-in field (stage, priority, assignee, tag, due date, state, creator, text, linked) or any custom field, with twelve comparisons from <code>is</code> to <code>between</code>. &quot;Mine and high priority, or anything of Ann&#39;s&quot; is this, straight from the tests:</p>
<pre><code class="language-ts">const f: FilterNode = {
  op: &#39;or&#39;,
  children: [
    {
      op: &#39;and&#39;,
      children: [
        { field: &#39;assignee&#39;, cmp: &#39;is&#39;, value: &#39;me&#39; },
        { field: &#39;priority&#39;, cmp: &#39;is&#39;, value: &#39;hi&#39; },
      ],
    },
    { field: &#39;assignee&#39;, cmp: &#39;is&#39;, value: &#39;ann&#39; },
  ],
};
</code></pre>
<p>As a Firestore query, that OR spans two fields, and the tree can nest as deep as people like. As a function over an array, it&#39;s a recursive <code>evalNode</code>.</p>
<h2>One function draws every view</h2>
<p>The whole engine is one call:</p>
<pre><code class="language-ts">applyView(tickets, view, board, ctx) → groups[]
</code></pre>
<p>It does four things, in this order:</p>
<ol>
<li>Keep the ticket states the view asks for. The default is active only, so archived tickets leave every default view.</li>
<li>Run the filter tree.</li>
<li>Sort on several keys, ties broken by rank and then id, so every client gets the same order.</li>
<li>Group, then group again inside each group for swimlanes.</li>
</ol>
<p>Kanban, table, calendar and timeline all draw from what it returns. A ticket with two assignees or three tags appears in each of their groups. Swimlanes are computed over all visible tickets, so every column has the same rows.</p>
<p><code>me</code>, <code>today</code>, <code>thisWeek</code> and <code>overdue</code> are tokens in the saved view, resolved when the function runs, against the viewer&#39;s id and time zone. A shared view called &quot;Mine, due this week&quot; means the same thing to everyone who opens it, each in their own week.</p>
<p>Because the function is pure, it&#39;s tested as one: the test file is 453 lines against 665 lines of engine, with a fixed clock (Tuesday 2026-09-22 in Kolkata) and a board with six custom field types.</p>
<h2>The one query, and what it costs</h2>
<p>The query that feeds the engine is about as simple as Firestore gets: the board&#39;s tickets where <code>state == &#39;active&#39;</code>, one listener per board. It needs no composite index.</p>
<p>Loading the whole board on every open would pay one read per card. So the board store is incremental. The first open runs the full query. After that, the device starts from Firestore&#39;s own IndexedDB cache of the last snapshot and asks only for tickets whose <code>updatedAt</code> is newer than the newest one it has seen. An unchanged board answers that with no documents, which Firestore bills as one read, whatever the board&#39;s size.</p>
<p>The delta query has no <code>state</code> filter, on purpose. A ticket that leaves the active set has to arrive so it can be dropped from the board, and a query for active tickets would never send it. The browser decides what to keep. One inequality on one field also means the delta needs no composite index.</p>
<p>A hard delete doesn&#39;t show up in any <code>updatedAt &gt;</code> query, because the document is gone. So the watermark is trusted for 6 hours from when it was written. After that, the next open pays for one full query and agrees with the server again.</p>
<h2>Where it stops</h2>
<p>The decision writes down its own ceiling: about 5,000 active tickets, or about 5 MB, per board. Past that, the plan is to flag the board as large and run its views through Typesense, which can filter on every field, with paging. Archived tickets are never loaded by default, so the number that matters is open work, not history.</p>
<p>For a personal tool, where a board is the unit people share, 5,000 open tickets is far more than a board should hold. The trade is the right one for this product. It wouldn&#39;t be for a tracker that hosts a whole company on one board.</p>
<p>What I&#39;d take from this for any Firestore app with user-built filters: if the data a screen needs fits on the device, query it once, keep it live, and do the rest in code you can unit test. Indexes are for the data that doesn&#39;t fit.</p>
<p>Where do your filters run: in the database, or over data you&#39;ve already loaded?</p>
]]></content:encoded>
</item>
<item>
  <title>Ticket keys in Firestore: issued once, never reused after delete</title>
  <link>https://panth.vardayinitech.in/blog/ticket-id-not-ticket-key</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/ticket-id-not-ticket-key</guid>
  <pubDate>Tue, 20 Oct 2026 00:00:00 GMT</pubDate>
  <description>In my tracker, ENG-42 is a name, not an identity. Tickets get a generated id, keys live in their own index, a key is issued once by a counter and never again, and a deleted ticket leaves a tombstone so old links say &apos;deleted&apos; instead of opening someone else&apos;s work.</description>
  <category>firestore</category><category>data-modeling</category><category>architecture</category><category>open-source</category>
  <content:encoded><![CDATA[<p>I split the ticket id from the ticket key so that tickets could move between boards, and then I took moving tickets out of the product.</p>
<p>The split stayed. A key like <code>ENG-42</code> gets pasted into WhatsApp messages, emails, GitHub PR titles and other tickets, and every one of those has to keep pointing at the right ticket for as long as the link exists, including after the ticket is gone.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. This is from <a href="https://github.com/Panth977/ticket-tracker">ticket-tracker</a>, my open-source board, written to replace a tracker called TeamTrack. Its decisions page lists the choices that are expensive to reverse. This is D4.</p>
<h2>The decision</h2>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Chosen</strong></td>
<td>A generated, immutable <code>ticketId</code>, a <code>key</code> field, and a <code>keys</code> index that keeps old keys as redirects</td>
</tr>
<tr>
<td><strong>Rejected</strong></td>
<td>The key as the document id, which is what TeamTrack did with <code>TICKET_ID</code></td>
</tr>
<tr>
<td><strong>Why</strong></td>
<td>Keys change when a ticket moves. Links in emails, WhatsApp messages, GitHub PRs and other tickets&#39; <code>#refs</code> must keep working after that.</td>
</tr>
</tbody></table>
<p>So a ticket lives at <code>boards/{boardId}/tickets/{ticketId}</code>, and <code>ENG-42</code> is a field on it. The key also gets a document of its own at the top level:</p>
<pre><code class="language-ts">// keys/{ticketKey}: &#39;#ENG-42&#39; → the ticket it names
export const KeyIndexSchema = z.object({
  ticketId: TicketIdSchema,
  boardId: BoardIdSchema,
  /** The key itself, equal to the doc id. */
  current: TicketKeySchema,
  deleted: z.boolean().optional(),
});
</code></pre>
<p><code>current</code> is the redirect: an old key&#39;s row can name the key the ticket has now. Exact-key search follows one hop of it.</p>
<h2>Then the move was dropped</h2>
<p>Phase 1 built moving a ticket to another board: copy the ticket, its thread, activity and files to the other board, give it a new key, and redirect the old one.</p>
<p>Decision D13 took it out. Moving is machinery (option mapping per field, assignees who aren&#39;t on the new board, a queued copy job, the key redirects) for something that happens rarely in a personal tool, and it was the most dangerous write in the system. Now a ticket stays on the board it was created on. To connect work across boards, you link the tickets or <code>#reference</code> them. A ticket filed on the wrong board gets closed and filed again, and the new one can reference the old.</p>
<p>So the reason written next to D4 no longer happens. Nothing writes a redirect today. The id and the key stayed separate anyway, because the same split does three other jobs.</p>
<h2>1. References store the id, not the key</h2>
<p>When you type <code>#ENG-42</code> in a comment, the editor stores a <code>ticketRef</code> node with the ticket&#39;s id. The key in that node is only a hint for rendering:</p>
<pre><code class="language-ts">case &#39;ticketRef&#39;: {
  const id = String(n.attrs?.ticketId ?? &#39;&#39;);
  if (id &amp;&amp; !refs.includes(id)) refs.push(id);
  const hint = typeof n.attrs?.key === &#39;string&#39; &amp;&amp; n.attrs.key ? n.attrs.key : &#39;ticket&#39;;
  line += `#${opts.keyOf?.(id) ?? hint}`;
  return;
}
</code></pre>
<p>The server derives each message&#39;s <code>refs</code> as a list of ticket ids. Mentions of people work the same way: a mention stores a uid and shows the current display name, so a rename never breaks it. TeamTrack stored mentions as <code>[@Name](mailto:…)</code> links in Markdown and keyed assignees by email, so a change of address was a data migration.</p>
<h2>2. A key is issued once</h2>
<p>Keys come from a counter on the board document, inside the same transaction that creates the ticket. Not from a query for the highest number:</p>
<pre><code class="language-ts">const start = board.nextNumber;
tx.update(ref, { nextNumber: start + ticketIds.length });
return ticketIds.map((ticketId, i) =&gt; {
  const number = start + i;
  const key = ticketKey(board.key, number);
  tx.create(keyRef(key), { ticketId, boardId, current: key });
  return { key, number };
});
</code></pre>
<p><code>tx.create</code> fails if the document already exists. A key that was ever handed out, whether it now points at a live ticket, a deleted one or (once) a moved one, can&#39;t be overwritten. The transaction fails instead.</p>
<p>The price is in the comment above that code: the board document takes about one sustained write a second, so one new ticket per second per board. Bulk creation takes a range with a single counter bump.</p>
<h2>3. Delete leaves a tombstone</h2>
<p>Deleting a ticket removes it from the links and references of other tickets, takes it off watchers&#39; lists, deletes the ticket document and its thread, and keeps the key:</p>
<pre><code class="language-ts">// Tombstone: kept forever, never reissued.
const tomb: KeyIndex = k
  ? { ...k, deleted: true }
  : { ticketId, boardId, current: ticket.key, deleted: true };
tx.set(keyRefs[i]!, tomb);
</code></pre>
<p>After that, <code>#ENG-42</code> in an old comment says the ticket was deleted. With the key as the document id, deleting the ticket deletes the only record that the key was ever used. If that key were ever issued again, every old link would open the new ticket as if nothing had happened. The comment in the schema says it more directly: the row is kept &quot;so an old link says &#39;this ticket is gone&#39; instead of landing on somebody else&#39;s work&quot;.</p>
<p>Deleting a whole board does the same for every ticket on it, and its board key stays claimed too. Board keys like <code>ENG</code> are claimed across the whole app, first come first served, like a username. That is what lets <code>/t/ENG-42</code> and <code>#ENG-42</code> mean one ticket without saying whose board it is.</p>
<h2>Who reads the index</h2>
<p>Everything that starts from a key goes through <code>keys/</code>, not through a board:</p>
<ul>
<li>the REST and MCP doors, when an agent or a script asks for <code>ENG-42</code></li>
<li>the GitHub hook, which finds keys in branch names, PR titles, commit messages and comments, and posts a line in each ticket&#39;s thread</li>
<li>Markdown coming in through REST or MCP, where <code>#ENG-42</code> is turned into a ticket id before it is stored</li>
<li>the search box, for an exact key</li>
</ul>
<p>A tombstoned key resolves to nothing for all of them. Over REST and MCP it is a 404, and so is a key on a board the caller can&#39;t read.</p>
<h2>What it cost</h2>
<p>One extra document per ticket, and one extra read whenever something arrives by key instead of by id: a pasted key, a webhook, a search. Links between tickets store only the id, so finding one looks on the same board first, which is almost always right, and falls back to the <code>keys</code> index, whose rows carry the board.</p>
<p>What I&#39;d keep from this for any system with human-readable ids: let the name be a field, give it its own index, issue it once, and never delete the index row. The move that justified D4 is gone, and the split still earns its place on delete alone.</p>
<p>Have you ever reused an id after a delete, and found out from a link that opened the wrong thing?</p>
]]></content:encoded>
</item>
<item>
  <title>10 TypeScript files, 5 SDK artefacts: typed in Node and Deno</title>
  <link>https://panth.vardayinitech.in/blog/one-sdk-source-five-artefacts</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/one-sdk-source-five-artefacts</guid>
  <pubDate>Mon, 19 Oct 2026 00:00:00 GMT</pubDate>
  <description>My tracker&apos;s TypeScript SDK is 10 source files and one build script. The script concatenates them into a single file and derives five artefacts from it: sdk.ts for Deno and Bun, a flat sdk.d.ts, sdk.js and sdk.min.js, and an npm tarball. Then fixtures prove the types arrive in every runtime, and the deploy refuses a stale build.</description>
  <category>typescript</category><category>sdk</category><category>deno</category><category>npm</category><category>open-source</category>
  <content:encoded><![CDATA[<p>Ten TypeScript files go in, five artefacts come out, and the client is typed in Node, Deno, Bun and the browser. One build script does it, and the SDK has no dependencies.</p>
<p>The constraint: an orchestrator should be able to import the client straight from a URL and get types, and nothing I host may be a second copy of the code that drifts from the first.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. This is from <a href="https://github.com/Panth977/ticket-tracker">ticket-tracker</a>, my open-source board, where AI agents work tickets alongside people. Writing a client by hand for every orchestrator is the thing the app exists to avoid, so it ships one: <code>@tm/sdk</code>, hosted next to the API it talks to.</p>
<h2>What gets hosted</h2>
<pre><code class="language-text">/lib/v1/sdk.js              ESM bundle, no dependencies   (browsers, Deno, Bun)
/lib/v1/sdk.min.js          the same, minified
/lib/v1/sdk.ts              the whole SDK as ONE TypeScript file
/lib/v1/sdk.d.ts            one flat declaration file, no imports
/lib/v1/tm-sdk-1.6.0.tgz    and tm-sdk.tgz, for npm install
/lib/v1/openapi.json        OpenAPI 3.1 for /v1
/lib/v1/sdk.json            version, byte sizes, SHA-256 of every file
/lib/latest/…               the same files, never cached
</code></pre>
<p>The <code>v1</code> is the API&#39;s wire version, not the package&#39;s. A breaking change to the wire format would get <code>/lib/v2/</code>, and <code>/lib/v1/</code> keeps working. A new SDK build for the same wire version replaces the files in place under a new tarball name.</p>
<h2>Concatenate first</h2>
<p><code>sdk/build.mjs</code> is 237 lines. The order of the steps is the whole idea:</p>
<pre><code class="language-text">sdk/src/*.ts  ──concat, imports stripped──▶  dist/sdk.ts
                                               │
                    tsc --emitDeclarationOnly  │        esbuild
                                               ▼            ▼
                                         dist/sdk.d.ts   dist/sdk.js + sdk.min.js
                                                            │  npm pack
                                                            ▼
                                                  dist/tm-sdk-&lt;version&gt;.tgz
</code></pre>
<p>Step one glues the 10 modules in <code>sdk/src/</code> into one file, in a fixed order, and drops every <code>import … from &#39;./x.js&#39;</code> line. Anything that imports a package rather than a sibling is a bug, so the script throws instead of dropping it:</p>
<pre><code class="language-javascript">const from = stmt.match(/from\s+&#39;([^&#39;]+)&#39;/);
if (from &amp;&amp; !from[1].startsWith(&#39;.&#39;))
  throw new Error(`${file}: the SDK must have no dependencies, but it imports &#39;${from[1]}&#39;`);
</code></pre>
<p>Everything else is derived from that one file. That&#39;s what keeps the promise cheap.</p>
<ul>
<li><strong><code>sdk.d.ts</code></strong> is emitted by <code>tsc</code> from a file that has no imports. Inside the repo the SDK&#39;s types come from the same shared zod schemas the server validates with, so a wrong field is a compile error rather than a 400. But a URL import can&#39;t resolve <code>zod</code> or the shared package. Emitting from the concatenated file means the declaration file can&#39;t come out mentioning them. It is also a full strict type-check on the way.</li>
<li><strong><code>sdk.ts</code></strong> is the exact file that was type-checked and bundled. Deno and Bun import it by URL and type it natively. It isn&#39;t a second copy.</li>
<li><strong><code>sdk.js</code> and <code>sdk.min.js</code></strong> come from esbuild, and both start with <code>/// &lt;reference types=&quot;./sdk.d.ts&quot; /&gt;</code>, which Deno and Bun read.</li>
<li><strong>The tarball</strong> carries a <code>package.json</code> with <code>types</code> first in its <code>exports</code> map, so tsc and editors resolve the declaration file. Node can&#39;t import over https, so this is the supported path there.</li>
</ul>
<p>At the end, the build imports each bundle, checks the exports and the version stamp, creates a client with a fake <code>fetch</code>, and asks the MCP tool factory for a tool. It also fails if <code>sdk.d.ts</code> has a single <code>import</code> line. A broken build never reaches the deploy.</p>
<h2>The header that turned .ts into video</h2>
<p>Hosting is Firebase, and the headers come from <code>firebase.json</code>. Three rules matter.</p>
<table>
<thead>
<tr>
<th>Path</th>
<th>Header</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td><code>/lib/v1/**</code></td>
<td><code>Cache-Control: public, max-age=31536000, immutable</code></td>
<td>A URL import in a hot loop must not cost a round trip</td>
</tr>
<tr>
<td><code>/lib/latest/**</code></td>
<td><code>Cache-Control: no-store</code></td>
<td>A moving name is only useful if it moves</td>
</tr>
<tr>
<td><code>/lib/**/*.ts</code></td>
<td><code>Content-Type: application/typescript</code></td>
<td>Otherwise <code>.ts</code> goes out as <code>video/mp2t</code></td>
</tr>
</tbody></table>
<p>The last one is real. Left alone, Hosting serves a <code>.ts</code> file as an MPEG transport stream, and Deno refuses it. <code>.d.ts</code> also ends in <code>.ts</code>, so one rule covers both. Next to it, <code>/lib/v1/sdk*.js</code> gets <code>X-TypeScript-Types: /lib/v1/sdk.d.ts</code>, which is what makes <code>import … from &#39;https://…/sdk.js&#39;</code> typed in Deno.</p>
<p>Header rules match the request path, not the file. <code>/lib/index.html</code> doesn&#39;t match a request for <code>/lib/</code>, so the install page&#39;s rule is spelled three ways. This looks right in review and is wrong in production, which is why the headers are asserted rather than argued: a check starts the real Hosting emulator on the real config, fetches all eighteen files and asserts the content type, cache rule, CORS header and <code>X-TypeScript-Types</code> on each.</p>
<h2>Proving the types arrive</h2>
<p>&quot;It compiles&quot; proves little if the types never loaded, because untyped code compiles too. <code>sdk/fixtures/check.mjs</code> runs one check per runtime against a local stand-in that sends the same headers as <code>firebase.json</code>:</p>
<ul>
<li><strong>Node:</strong> <code>npm i</code> the real tarball into a project of its own, then <code>tsc --strict</code>.</li>
<li><strong>Deno:</strong> <code>deno check</code> a script that imports <code>sdk.ts</code>, another that imports <code>sdk.js</code>, and a third that gets only the <code>X-TypeScript-Types</code> header.</li>
</ul>
<p>Every fixture ends in <code>@ts-expect-error</code> lines. They fail if the mistake compiles, and they also fail if the types never resolved, since an expected error that never happens is itself an error. So &quot;no types&quot; can&#39;t pass as &quot;types fine&quot;. And there is a negative control: the same bundle with both type hints removed must fail <code>deno check</code>. It does. If it ever passed, the checks above it would prove nothing, and the script says so.</p>
<h2>The deploy won&#39;t ship a stale bundle</h2>
<p>The SDK is built by <code>pnpm sdk:build</code>, not by the deploy. So the deploy checks what it&#39;s about to upload: every artefact exists, carries the version <code>sdk/package.json</code> claims, starts with the triple-slash reference, has a flat declaration file, and is newer than every file under <code>sdk/src</code>. If not, it stops and prints the command to run.</p>
<p>The failure it guards against is quiet: yesterday&#39;s bundle going out next to today&#39;s API. Nothing would error. A field would just be missing from the types until someone hit it.</p>
<h2>What it costs</h2>
<p>The rule is &quot;derive, don&#39;t copy&quot;, and the price is mostly the checks. The build script is 237 lines, and the fixture and header checks add their own. In return there is one place to change the client, and the four runtimes see the same code with the same types.</p>
<p>How do you ship a typed client to users who import it over a URL rather than from npm?</p>
]]></content:encoded>
</item>
<item>
  <title>llms-full.txt for agents: 6,245 lines, 1,800 written by hand</title>
  <link>https://panth.vardayinitech.in/blog/llms-full-txt-one-page</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/llms-full-txt-one-page</guid>
  <pubDate>Sun, 18 Oct 2026 00:00:00 GMT</pubDate>
  <description>My tracker publishes one 6,245-line Markdown file, /llms-full.txt, that tells an agent everything it needs to work there. About 1,800 lines are prose I write. The rest is generated from the OpenAPI document, the MCP registry, the shared contracts and the SDK&apos;s declaration file, and the deploy fails if the committed copy is out of date.</description>
  <category>llms-txt</category><category>agents</category><category>mcp</category><category>documentation</category><category>open-source</category>
  <content:encoded><![CDATA[<p>An agent that joins my tracker reads one URL: <code>/llms-full.txt</code>. It is 6,245 lines of Markdown, and I wrote about 1,800 of them. The rest is generated from the code, and the deploy refuses to ship a copy that has drifted from it.</p>
<p>The constraint: an agent should need one page, not a tour of the docs site, and that page can&#39;t be a second copy of the truth that rots the week after it&#39;s written.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. This is from <a href="https://github.com/Panth977/ticket-tracker">ticket-tracker</a>, my open-source board, which my own agents work from. Section O of its agent plan is titled &quot;One page an orchestrator can be pointed at&quot;. This post is how that page is built.</p>
<h2>Four files, one source</h2>
<p>The app serves these beside itself:</p>
<pre><code class="language-text">/llms.txt          the short index: what this is, the URLs, how to authenticate
/llms-full.txt     the one page: the whole operating context, Markdown
/integrate         the same content as a readable web page, for people
/integrate.json    a small manifest: api, mcp, sdk, openapi and llms URLs + version
</code></pre>
<p><code>/llms.txt</code> is 85 lines and mostly says &quot;read <code>/llms-full.txt</code>&quot;. The full page has nine chapters: what the software is, how an agent gets in, the three doors (SDK, REST, MCP), the loop as a complete runnable orchestrator, the reference, the rules of the house, recipes, artifacts, and the version and changelog.</p>
<p>All four come out of one script, <code>scripts/gen-integration.mjs</code>. It also builds the Claude Code plugin, whose skill ships <code>llms-full.txt</code> verbatim as its reference file, so the plugin can&#39;t hold a second copy either.</p>
<h2>What is written by hand, and what isn&#39;t</h2>
<p>The prose lives in <code>scripts/integration-source/</code>, one Markdown file per chapter. That is the only hand-written part. Where the prose needs a table that the code already knows, it holds a marker instead:</p>
<pre><code class="language-markdown">## 5. Reference
...
{{gen:contracts}}
...
{{gen:rest}}
...
{{gen:mcp}}
...
{{gen:sdk}}
</code></pre>
<p>The generator replaces each marker with a block built from the same sources the software runs on:</p>
<table>
<thead>
<tr>
<th>Block</th>
<th>Comes from</th>
</tr>
</thead>
<tbody><tr>
<td>REST: every endpoint, its parameters, body and responses</td>
<td><code>/v1/openapi.json</code>, generated from the zod schemas the routes parse with</td>
</tr>
<tr>
<td>MCP: every tool, its scopes and input schema</td>
<td>the tool registry in the shared package</td>
</tr>
<tr>
<td>Scopes, event types, error codes, field types, states</td>
<td>the shared contracts, from the built package</td>
</tr>
<tr>
<td>SDK: every method</td>
<td><code>sdk/dist/sdk.d.ts</code>, quoted verbatim with its doc comments</td>
</tr>
</tbody></table>
<p>The reference chapter alone is 3,218 lines: 72 REST endpoints and 36 MCP tools. Nobody types those. When a route is added, it shows up in the page on the next build because the route&#39;s schema is the page&#39;s source.</p>
<p>The expansion is strict. A marker the generator doesn&#39;t know stops it:</p>
<pre><code class="language-javascript">.replace(/\{\{gen:([a-z]+)\}\}/g, (_, name) =&gt; {
  if (!(name in blocks)) fail(`unknown generated block {{gen:${name}}} in the prose`);
  return blocks[name];
})
</code></pre>
<p>Some tables need a sentence of meaning next to each generated name, like what an agent with the <code>commenter</code> role may do. Those sentences are written in the script, keyed by name. If the shared package grows a new role and nobody wrote what it means, the build fails instead of printing a blank row.</p>
<h2>The guard</h2>
<p><code>node scripts/gen-integration.mjs --check</code> generates everything fresh and compares it with the committed copy. The deploy script runs it before <code>firebase deploy</code>, so nothing goes out on a stale page. If I change a schema and forget <code>pnpm integrate:gen</code>, the deploy stops.</p>
<p>That only works if the same inputs give the same bytes. So every list is emitted in a fixed order and nothing reads the clock. The page&#39;s &quot;updated&quot; date is the date of the newest changelog entry, which an author bumps when the contract moves. A <code>new Date()</code> stamp would make the guard fail every midnight.</p>
<p>The guard is tested too. The end-to-end suite generates into a temp directory, appends a line to each file in turn and checks that exactly that file is reported stale. The plugin zip is compared as bytes, so the test flips one byte in the middle of it and expects the same.</p>
<h2>Served as text, uncached</h2>
<p>The page exists to answer &quot;has anything changed?&quot;, so a cached copy defeats it, and a model handed HTML where it expected Markdown is worse off than with no page. Hosting serves <code>/llms*.txt</code> as <code>text/plain; charset=utf-8</code> with <code>no-store</code> and <code>access-control-allow-origin: *</code>. A test reads <code>firebase.json</code> and asserts those headers, and a separate check proves them against the hosting emulator.</p>
<h2>What an agent gets from it</h2>
<p>A person creates an agent, adds it to a board and gives it a token. The agent gets that token and one URL. From the page it learns how to wait for work without polling, publish a task list, heartbeat every minute, ask a question when it is blocked and post its result with files, with every endpoint and tool it needs in the same file.</p>
<p>The changelog at the end goes back to version 1.0.0 on 2026-09-26, so an agent can tell whether what it learned last week still holds.</p>
<p>Where do your agents learn your API from, and what stops that from going stale?</p>
]]></content:encoded>
</item>
<item>
  <title>Polling was 88% of my API calls: agents now wake on Firebase SSE</title>
  <link>https://panth.vardayinitech.in/blog/wake-on-a-revision-not-polling</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/wake-on-a-revision-not-polling</guid>
  <pubDate>Fri, 16 Oct 2026 00:00:00 GMT</pubDate>
  <description>Two orchestrator loops polling every 30 seconds were 88% of every request my tracker&apos;s API served. Now an agent holds one Realtime Database stream on a tiny revision node, and calls the API only when that node moves, asking for the delta. Here is the node, the credential, and the two details that keep a flapping stream from turning back into a poll.</description>
  <category>firebase</category><category>realtime-database</category><category>sse</category><category>agents</category><category>open-source</category>
  <content:encoded><![CDATA[<p>8,822 calls to <code>GET /v1/tickets</code> and 8,815 calls to <code>GET /v1/events</code> in three days. Together they were 88% of every request my tracker&#39;s API served, from an app used by one person and two agents.</p>
<p>The constraint: the agents have to notice new work within seconds, and an idle agent should cost nothing.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. This is from <a href="https://github.com/Panth977/ticket-tracker">ticket-tracker</a>, my open-source board, which my agents work from. Its plan has a section on what the app costs, and this post is the part of it about the agents: they used to poll, and now they wake.</p>
<h2>What the polling cost</h2>
<p>The numbers are from 23 to 26 September 2026.</p>
<table>
<thead>
<tr>
<th>What</th>
<th>Measured</th>
</tr>
</thead>
<tbody><tr>
<td>Requests to the API function</td>
<td>41,515 in 3 days</td>
</tr>
<tr>
<td>Billable vCPU-seconds that were the API</td>
<td>11,963 of 13,775 (87%)</td>
</tr>
<tr>
<td>Orchestrator polling</td>
<td>8,822 tickets + 8,815 events = 88% of requests</td>
</tr>
<tr>
<td>Time per call</td>
<td>about 250 ms</td>
</tr>
</tbody></table>
<p>Two orchestrator loops ran every 30 seconds or so. Each one asked for the full list every time: tickets with <code>limit=200</code> and no <code>updated_since</code>, events with <code>unacked=1</code> and no cursor. The answer was the same almost every time, and it was paid for every time.</p>
<p>The API runs on Cloud Run, which bills CPU for the whole request, including the time it spends waiting on I/O. Each call did an API-key lookup, a board read, an agent read and two rate-limit transactions before the actual work. An idle agent was the largest line on the bill.</p>
<h2>The shape that costs nothing while idle</h2>
<p>The API&#39;s data is in Firestore, which bills per operation. The Realtime Database bills for bandwidth. So the signal lives in the Realtime Database, and the data stays where it is.</p>
<p>Two tiny nodes carry the signal:</p>
<pre><code>rev/{boardId}            &#39;something on this board changed&#39;
agents/{agentId}/wake    &#39;something is in your inbox&#39;
</code></pre>
<p>Every write goes through one command layer. After a command succeeds, it overwrites the board&#39;s <code>rev</code> node with about 40 bytes:</p>
<pre><code class="language-typescript">export const bumpBoardRev = (boardId: string, by: string | null, at: number): Promise&lt;void&gt; =&gt;
  liveSafe(`rev ${boardId}`, () =&gt;
    rtdbAdmin()
      .ref(live.rev(boardId))
      .set({ at, ...(by ? { by } : {}) }),
  );
</code></pre>
<p>Nothing about the change travels through it. The node only says &quot;ask again&quot;. Two commands don&#39;t move it: <code>ping</code>, which changes nothing, and the agent heartbeat, which has its own node. Bumping on a heartbeat would wake every watcher of the board once a minute per agent, which is the polling all over again.</p>
<p>The agent&#39;s side is one long-lived stream on those nodes, then one REST call that asks only for what changed: <code>updated_since</code> for tickets, a <code>cursor</code> for events. One Realtime Database write per command, one small query per real change, and nothing at all while the board is quiet.</p>
<h2>Streaming without the Firebase SDK</h2>
<p>The Realtime Database&#39;s REST API streams a node as plain Server-Sent Events. Ask for <code>{database_url}/{path}.json</code> with <code>accept: text/event-stream</code> and you get <code>put</code> and <code>patch</code> frames as the node changes. There is no Firebase SDK in the SDK, so the same code runs in Node, Deno and Bun. An idle stream is an open socket and close to zero bytes.</p>
<p>The stream needs a credential, and an agent holds an API token, not a Firebase login. <code>GET /v1/live</code> bridges that. It mints a Firebase custom token for the token&#39;s principal, with a <code>board</code> claim for an agent&#39;s board, exchanges it server-side for an ID token, and returns the database URL, the token, its lifetime and the exact paths it may stream. The database rules then let an agent read its own wake node and its board&#39;s <code>rev</code>, and nothing else. The watcher re-mints when the credential expires.</p>
<h2>Two details that keep it from becoming a poll again</h2>
<p><strong>A reconnect is not a change.</strong> The Realtime Database replays the node&#39;s current value as a <code>put</code> every time a stream connects. If every frame woke the agent, a flapping connection would be a polling loop with extra steps. So a frame only counts when its <code>at</code> timestamp has moved past the last one seen, and the first frame after a connect only records where things stand.</p>
<p><strong>Ten bumps are one fetch.</strong> The watcher keeps at most one wake queued. If ten commands land while the agent is busy, it wakes once and makes one delta fetch, because the delta covers all ten. A real change replaces a waiting backstop wake, never the other way round.</p>
<h2>When the stream can&#39;t open</h2>
<p>Some setups can&#39;t hold an SSE stream: a proxy that eats it, a runtime without streaming bodies, an older server without <code>/v1/live</code>. Then the watcher polls, and says why. The poll starts at 2 seconds while work is arriving and backs off to 60 seconds when nothing is found. The caller tells it which, with the number of rows the last delta returned:</p>
<pre><code class="language-typescript">const w = tm.watch();
for await (const signal of w) {
  const page = await tm.events.list({ cursor, unacked: true });
  cursor = page.next_cursor ?? cursor;
  for (const ev of page.data) handle(ev);
  w.found(page.data.length);        // 0 ⇒ back off, &gt;0 ⇒ stay eager
}
</code></pre>
<p>Even while streaming, the SDK fetches once every 15 minutes in case a bump was lost. That backstop is the only REST traffic an idle streaming watcher makes.</p>
<h2>What it was for</h2>
<p>The target in the plan is 50 boards with 10 live tickets each, always on, for 50 rupees a month or less. That only works if the steady state sits inside Firestore&#39;s free daily quota and the API functions stay off the read path. Agents that poll every 30 seconds can&#39;t fit in that. Agents that wake on a 40-byte write can.</p>
<p>If your workers poll an API on a timer, what share of your requests is them asking whether anything changed?</p>
]]></content:encoded>
</item>
<item>
  <title>MCP on OAuth 2.1: one URL and a consent click, no API key</title>
  <link>https://panth.vardayinitech.in/blog/mcp-oauth-not-api-keys</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/mcp-oauth-not-api-keys</guid>
  <pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate>
  <description>My tracker&apos;s MCP server takes OAuth 2.1 with PKCE and dynamic client registration instead of a pasted API key. A person adds it in Claude with one URL and a consent click, picks the boards it may touch, and can revoke it in one place. Here is the flow, the token lifetimes, and the one rule that catches a stolen refresh token.</description>
  <category>mcp</category><category>oauth</category><category>security</category><category>open-source</category>
  <content:encoded><![CDATA[<p>Adding my tracker to Claude takes one URL and one click on a consent screen. Nobody makes a key, copies it, or pastes it anywhere.</p>
<p>The constraint: an MCP client that a person uses every day should not hold a long-lived secret the person had to handle by hand, and the person should be able to see and cut off what it can reach.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. This is from <a href="https://github.com/Panth977/ticket-tracker">ticket-tracker</a>, my open-source board. Its decisions page lists the choices that are expensive to reverse, each with the alternative that was turned down. This is D9.</p>
<h2>The decision</h2>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Chosen</strong></td>
<td>OAuth 2.1 with PKCE, dynamic client registration, and protected-resource metadata, as the MCP authorization spec expects</td>
</tr>
<tr>
<td><strong>Rejected</strong></td>
<td>Pasting an API key into the MCP client</td>
</tr>
<tr>
<td><strong>Why</strong></td>
<td>It is the only way a person adds the server with one URL and a consent click, in Claude and other clients. The grant is per person, can be revoked in Account › Connected apps, and every change it makes is attributed <code>via MCP</code>.</td>
</tr>
</tbody></table>
<p>API keys did not go away. They still work on <code>/mcp</code> for headless setups, where nobody is there to click Allow. Claude Code takes a token in a header, and so do the orchestrators that run my agents. OAuth is for the case where a person is sitting in front of the client.</p>
<h2>What the client does, with nobody pasting anything</h2>
<p>The whole flow is discovery. The client starts knowing one URL, and every other address comes from a document the server publishes.</p>
<pre><code class="language-text">1. POST /mcp, no token
   ← 401  WWW-Authenticate: Bearer resource_metadata=&quot;.../.well-known/oauth-protected-resource/mcp&quot;
2. GET  /.well-known/oauth-protected-resource/mcp        (RFC 9728)
   ← { authorization_servers: [base], scopes_supported: [...] }
3. GET  /.well-known/oauth-authorization-server          (RFC 8414)
   ← authorize, token, register and revoke endpoints; PKCE S256 only
4. POST /oauth/register                                  (RFC 7591)
   ← client_id
5. GET  /oauth/authorize?...&amp;code_challenge=...  → our consent screen
6. person picks boards, presses Allow  → redirect with ?code=
7. POST /oauth/token  code + code_verifier  ← access + refresh tokens
</code></pre>
<p>Step 1 carries the design. The 401 is not &quot;your token is bad&quot;. It is &quot;go and sign in, and here is where to start&quot;. A client that has never heard of this server can follow the header all the way to a working token.</p>
<p>Some clients probe OIDC discovery first, so <code>/.well-known/openid-configuration</code> answers with the same document.</p>
<h2>The consent screen is where scope gets decided</h2>
<p>The screen names the app asking, lists the permissions it wants in plain words (&quot;Read tickets&quot;, &quot;Post, edit, pin and react in threads&quot;), and asks which boards it may act on: every board, or the ones you tick. Nothing is granted until you press Allow.</p>
<p>The server checks the ticked boards against the boards the person can actually read. A board you are not on is an error, not a silent drop.</p>
<p>What the grant gives back shapes the MCP server itself. A tool the grant&#39;s scopes do not allow is not registered at all, so it is missing from <code>tools/list</code>, and every handler checks again anyway. The model never sees a tool it would only get a 403 from.</p>
<h2>Token lifetimes</h2>
<table>
<thead>
<tr>
<th>Token</th>
<th>Lives</th>
<th>Rules</th>
</tr>
</thead>
<tbody><tr>
<td>Authorization code</td>
<td>60 s</td>
<td>Single use, bound to the client, the redirect URI and the PKCE S256 challenge</td>
</tr>
<tr>
<td>Access token</td>
<td>1 h</td>
<td></td>
</tr>
<tr>
<td>Refresh token</td>
<td>30 days</td>
<td>Rotated on every use</td>
</tr>
</tbody></table>
<p>Tokens are stored only as their SHA-256. The grant is a document under the person (<code>users/{uid}/oauthGrants/{grantId}</code>), and it is the revocation switch: revoking deletes it and every token issued under it. Revoking in Connected apps stops the app on its next call, including sessions already open.</p>
<p>A refresh may narrow scopes, never widen them. Asking for a scope the grant does not hold fails with <code>invalid_scope</code>.</p>
<h2>The rule that catches a stolen refresh token</h2>
<p>Rotation alone does not help if nobody notices the old token coming back. So a used refresh token is kept, marked with when it was used, until it expires. If it is presented again, that is two parties holding the same token: the real client and someone else. The server revokes the whole grant.</p>
<pre><code class="language-ts">// A client that refreshes twice at once, or retries after losing the response,
// presents the same token seconds apart: that is not theft.
const retry = row.usedAt !== undefined &amp;&amp; opts.now - row.usedAt &lt;= REFRESH_RETRY_GRACE_MS;
if (row.usedAt !== undefined &amp;&amp; !retry) {
  // THEFT DETECTION: an already-rotated refresh token came back.
  reused = { uid: row.uid, grantId: row.grantId };
  return null;
}
</code></pre>
<p>The grace window is 60 seconds. Without it, a client that fires two refreshes in parallel, or loses the response and retries, would lock its own user out. With it, a retry gets a fresh pair and a replay an hour later kills the grant with &quot;Refresh token reuse detected — the app must be connected again&quot;.</p>
<h2>The small checks that keep it from becoming an open door</h2>
<p>Dynamic registration means anyone can register a client, so the edges matter:</p>
<ul>
<li><strong>Registration is rate-limited</strong> per IP address: 20 a minute, 500 a day.</li>
<li><strong>Redirect URIs</strong> must be <code>https</code>, or <code>http</code> on <code>localhost</code>, <code>127.0.0.1</code> or <code>[::1]</code> only, or a private-use scheme such as <code>cursor://</code> or <code>vscode://</code>. <code>javascript:</code>, <code>data:</code>, <code>file:</code>, <code>vbscript:</code> and <code>blob:</code> are refused, and so is any URI with a fragment.</li>
<li><strong>A bad client or redirect URI is shown as an error page, never redirected to.</strong> Redirecting there would make the server an open redirector. Any other error goes back to the client as <code>?error=</code> the way RFC 6749 says.</li>
<li><strong>Only two grant types</strong>: <code>authorization_code</code> and <code>refresh_token</code>.</li>
</ul>
<h2>What it cost</h2>
<p>One more surface to own. An API key is one table and one header check. This is an authorization server with eight endpoints, a consent screen in the app, and a Connected apps page. My own review notes had a row for it: the MCP flow pointed at a consent page that did not exist yet. That was fixed by building the page at <code>/oauth/consent</code>, after which the grant shows in Connected apps.</p>
<p>For an MCP server people add to Claude by hand, I would pay it again. The person never touches a secret, sees exactly which boards they handed over, and has one Revoke button.</p>
<p>If you run an MCP server, which do your users hold today: an OAuth grant or a key they pasted?</p>
]]></content:encoded>
</item>
<item>
  <title>Our coding agent prompts live in the ticket, reviewed like PRs</title>
  <link>https://panth.vardayinitech.in/blog/prompts-stored-in-the-ticket</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/prompts-stored-in-the-ticket</guid>
  <pubDate>Tue, 13 Oct 2026 00:00:00 GMT</pubDate>
  <description>On my team at Oizom the only way to talk to the coding agent is the ticket thread, so every prompt is stored with the work it produced, next to the preview, the feedback and the cost of each turn. That makes a prompt something you can read back and review like a pull request.</description>
  <category>ai-agents</category><category>developer-experience</category><category>code-review</category><category>devops</category>
  <content:encoded><![CDATA[<p>Every prompt my team writes to our coding agent is stored in the ticket that asked for it. The ticket thread is the only way a developer can talk to the agent, so there is nowhere else for a prompt to go.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom, where Envizom is the platform that turns data from air-quality and environmental monitors into something cities and industries can act on. This post is about one design goal of our agent pipeline: the prompt is kept with the work, and it can be reviewed.</p>
<h2>The only channel is the thread</h2>
<p>A developer writes what they want in a ticket, in plain English, and drags it to <strong>Approved</strong>. There is no IDE open, no terminal and no prompt window. That ticket text is the whole spec.</p>
<p>Our orchestrator, orch, gives the ticket its own stack: a branch in every repo, its own backend, drivers, simulator and frontend, a copy of the dev database and a private preview URL. A coding agent does the work inside that stack. When it is done, it posts the preview link into the thread and moves the ticket to <strong>QAQC</strong>.</p>
<p>The developer opens the preview, tests it, replies in the same thread and moves the ticket back. The same agent, with the same context, picks up where it left off. Every follow-up is a message in the thread, so every follow-up is stored too.</p>
<p>When we set out to build this, it was one of five things we wanted from the start:</p>
<blockquote>
<p>Every prompt should be stored with the work it produced, so we can review and coach how people ask for things.</p>
</blockquote>
<h2>What one thread holds</h2>
<p>By the time a ticket is done, its thread holds the whole exchange, turn by turn:</p>
<table>
<thead>
<tr>
<th>In the thread</th>
<th>Who writes it</th>
</tr>
</thead>
<tbody><tr>
<td>What was asked</td>
<td>the developer, in the ticket and in each reply</td>
</tr>
<tr>
<td>What was built</td>
<td>the agent, with the preview link</td>
</tr>
<tr>
<td>Which preview was tested</td>
<td>the developer, by replying to that preview</td>
</tr>
<tr>
<td>The feedback</td>
<td>the developer, in the same thread</td>
</tr>
<tr>
<td>What the turn cost</td>
<td>posted on every agent turn</td>
</tr>
<tr>
<td>The PR and commit links</td>
<td>orch, after the ticket is dragged to <strong>Git Push</strong></td>
</tr>
</tbody></table>
<p>Here is the shape of a real one. In the walkthrough video I asked for a camera layout change on a device page:</p>
<pre><code class="language-text">ticket   camera layout change on the device page          -&gt; Approved
agent    builds it in the ticket&#39;s stack, posts the preview -&gt; QAQC
me       &quot;also handle more than 4 cameras with a horizontal scroll,
          and set up 8 test cameras on this device&quot;         -&gt; back to the agent
agent    does both, the 8 test cameras in the ticket&#39;s own
          database copy, posts the preview                  -&gt; QAQC
me       drags it to Git Push
orch     commits, opens and merges the PRs, posts the links,
          waits for CI, deploys to dev, checks health        -&gt; Completed
</code></pre>
<p>Every line of that is in the thread. Anyone can open the ticket and read what was asked, in the words it was asked in, next to what came out of it.</p>
<h2>Reading prompts like pull requests</h2>
<p>Because the prompts are stored, they can be reviewed. I read how each person on the team asks for things and coach them on it. That has been more useful than any prompting guide.</p>
<p>A prompting guide tells you what good asks look like in general. A ticket thread shows you how one person on your team asked for one change, what the agent built from it, and how many replies it took to get to something they were happy with. You can point at a specific message and talk about it, the way you would point at a line in a pull request.</p>
<p>The cost line matters here as well. Each agent turn posts its cost into the thread, right where the ask was made. The person asking sees what the ask cost, and it changes how they write the next one. That is on purpose too.</p>
<h2>Why it needs one stack per ticket</h2>
<p>A stored prompt is only useful if it sits next to the work it produced, and nothing else. That is where per-ticket isolation comes in.</p>
<p>Each ticket has its own branch in every repo, its own services and its own copy of the dev database. So a ticket&#39;s thread is a self-contained record: what was asked, what was built in that stack, what was tested on that preview. A ticket can sit in QA for a week while three others ship, and its history is still in one place, not mixed into anyone else&#39;s.</p>
<p>The thread is also where the rest of the process shows up. After <strong>Git Push</strong>, no agent is involved: orch commits, merges, opens and merges the pull requests, waits for CI, deploys to the dev VM and checks its health, and posts the PR and commit links back into the same thread. The prompt, the result and the review trail end up in one ticket.</p>
<h2>What we get from it</h2>
<ul>
<li>Every prompt the team writes is stored with the ticket it belongs to.</li>
<li>I can review how people ask for things using their own tickets, not examples from a guide.</li>
<li>The cost of each ask is visible where it was asked.</li>
<li>The ticket is the audit trail: what was asked, what was built, what was tested and what feedback was given.</li>
</ul>
<p>If your team uses coding agents, where do the prompts go once the work ships?</p>
<hr>
<p><em>I lead the software team at <a href="https://oizom.com">Oizom</a>. The full pipeline, including where we drew the line (dev only, human QA, visible cost), is in <a href="https://dev.to/panthpatel/the-ticket-is-the-interface-how-our-developers-ship-without-a-laptop-4of1">The ticket is the interface</a>.</em></p>
]]></content:encoded>
</item>
<item>
  <title>1 human check, 8 scripted steps: QA in our coding agent pipeline</title>
  <link>https://panth.vardayinitech.in/blog/qa-stays-human</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/qa-stays-human</guid>
  <pubDate>Sun, 11 Oct 2026 00:00:00 GMT</pubDate>
  <description>Our coding agent never marks its own work as done. It posts a preview link and moves the ticket to QAQC, a person tests it, and only after that do eight scripted steps take the change to the dev environment. Why we kept that one step human at Oizom.</description>
  <category>ai-agents</category><category>qa</category><category>devops</category><category>code-review</category>
  <content:encoded><![CDATA[<p>After a developer drags a ticket to Git Push, eight steps run on their own, from the commit to the health check on our dev VM. No agent is involved in any of them. The step right before them is always a person: the developer who asked for the change opens a preview and decides whether it is what they asked for.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom, where Envizom is the platform. This post is about that one human step in our agent pipeline: where it sits, what it checks, and why we kept it on purpose.</p>
<h2>Where QA sits</h2>
<p>A developer writes what they want in a ticket, in plain English, and drags it to <strong>Approved</strong>. Our orchestrator, orch, gives the ticket its own stack: a branch in every repo, its own services, a copy of the dev database and a private preview URL. A coding agent does the work inside that stack.</p>
<p>When the agent is done, it posts the preview link in the ticket thread and moves the ticket to <strong>QAQC</strong>. That is as far as it goes. The agent never marks its own work as done.</p>
<pre><code>Approved ──▶ agent works in the ticket&#39;s own stack
                │
                ▼
             QAQC  ◀──── feedback in the thread, ticket moved back
                │            (same agent, same context)
                │ a person tests the preview
                ▼
            Git Push ──▶ 8 scripted steps, no agent ──▶ dev VM
                                                       (rollback if unhealthy)
</code></pre>
<h2>What the person does</h2>
<p>The developer who asked opens the preview and tests it. If something is off, they reply in the same thread and move the ticket back. The same agent picks it up with the same context and carries on.</p>
<p>In our demo video I asked for a camera layout change on a device page. The agent shipped it and posted the preview. My QA reply was: &quot;also handle more than 4 cameras with a horizontal scroll, and set up 8 test cameras on this device&quot;. The agent did both, and it created the test data in the ticket&#39;s own copy of the database, so nothing it added touched anyone else&#39;s data.</p>
<p>That reply is a QA note and a new requirement in one message, and it went to the agent the same way the original ask did: as words in the ticket thread.</p>
<h2>Why it stays human</h2>
<p>The QA step is where someone checks that the thing that was built is the thing that was asked for.</p>
<p>If the agent graded its own work, the one review that matters most would disappear. Every step after it is built to be boring and repeatable, and none of them asks whether the change was the right change. QA is the only place in the pipeline where that question gets asked, so it is the one place we did not automate.</p>
<p>It also keeps the record honest. What was asked, what was built, which preview was tested and what feedback went back all sit in one ticket thread, and the decision to ship is a person&#39;s action in that thread, not the agent&#39;s.</p>
<h2>Everything after QA is a script</h2>
<p>When the developer is happy, they drag the ticket to <strong>Git Push</strong>. From there orch:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Step</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>commits the work on the ticket branches</td>
</tr>
<tr>
<td>2</td>
<td>merges the current dev branch into them</td>
</tr>
<tr>
<td>3</td>
<td>opens a pull request in each affected repo</td>
</tr>
<tr>
<td>4</td>
<td>merges the pull requests</td>
</tr>
<tr>
<td>5</td>
<td>posts the PR and commit links back into the ticket thread</td>
</tr>
<tr>
<td>6</td>
<td>waits for CI</td>
</tr>
<tr>
<td>7</td>
<td>rolls the new images onto the dev VM</td>
</tr>
<tr>
<td>8</td>
<td>runs health checks against the VM</td>
</tr>
</tbody></table>
<p>If the VM is not healthy afterwards, orch rolls it back to the previous images, reverts the merges, and tells the thread what happened. When it is healthy, the ticket moves to <strong>Completed</strong> and the developer gets a notification.</p>
<p>None of this is an agent. It is deterministic, it is tested, and when it breaks I read a log and fix it. That is what lets me leave the merge and the deploy to a machine: the only judgement call in the chain has already been made by a person.</p>
<h2>It stops at dev</h2>
<p>All of that automation, the auto-merge, the VM update, the rollback, stops at the dev environment. Production goes through a separate path where people make the call, on a slower cadence.</p>
<p>This is deliberate too. We do not want automation deciding what reaches production, and we do not want a fast feature list to become the goal. A human QA step on dev and a human decision for production are two different checks, and we kept both.</p>
<h2>My own review moved earlier</h2>
<p>QA is the developer&#39;s step. Mine happens before anything is built. I read the architecture docs, the plans and the prompts, and they tell me about 90% of what is coming. The other 10% is reviewing the output and writing QAQC cases.</p>
<p>So the agent sits between two kinds of human attention: the plan and the prompt going in, and the preview test coming out. It does the engineering in the middle, inside a stack that exists only for its ticket.</p>
<h2>If you are setting this up</h2>
<p>Give the agent a place to stop that is not &quot;done&quot;. For us that is a column called QAQC, a preview link and a thread where the reply goes. Make the person who asked for the change the one who tests it, because they are the only one who knows what they meant. Then script everything after that decision, and keep it out of production.</p>
<p>If your team runs coding agents, who decides that a task is done: the agent, or the person who asked for it?</p>
]]></content:encoded>
</item>
<item>
  <title>849 KB of spec before any code: building Octupuz, a puzzle feed</title>
  <link>https://panth.vardayinitech.in/blog/building-octupuz-puzzle-feed</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/building-octupuz-puzzle-feed</guid>
  <pubDate>Sat, 10 Oct 2026 00:00:00 GMT</pubDate>
  <description>Octupuz is a feed where every post is a puzzle. The backend knows nothing about puzzles: a post is a seed, the phone draws the board, and a finish only counts after the server replays the move log through the same rules code.</description>
  <category>javascript</category><category>game-development</category><category>postgres</category><category>architecture</category>
  <content:encoded><![CDATA[<p>Before the first line of Octupuz existed, its spec was 849 KB of JSON: 72 screens, 23 components, every animation researched from Instagram, and every decision written down with its reason. The first build worked from a summary of that spec instead of the spec. It looked like a prototype, contradicted decisions that were already settled, and was thrown away.</p>
<p>I&#39;m Panth. I lead the software team at Oizom, and Octupuz is my own project, built in about a month. This post is about the parts of it you can&#39;t see in a screenshot: why the backend knows nothing about puzzles, why a board has to come out the same on every device, and how the server checks a solve without trusting the phone.</p>
<h2>What Octupuz is</h2>
<p>A social feed that looks and moves like Instagram, except every post is a puzzle you play right there. 41 kinds are live: Queens, Nonogram, Pipes, Water Sort, Suguru, Skyscrapers, a whodunit called Case File, and more. New ones every day. When you solve one, your time lands on the post.</p>
<div class="video"><iframe src="https://www.youtube-nocookie.com/embed/Ql2M3Abjjqg" title="Octupuz: a feed where every post is a puzzle you can play" loading="lazy" allowfullscreen allow="accelerometer; encrypted-media; picture-in-picture"></iframe></div><p>It runs on the web and installs as a PWA at <a href="https://octupuz.in">octupuz.in</a>. The Play Store is next. The stack: Postgres on Supabase with row-level security, a Svelte client, Capacitor for the Android shell, and games in plain JavaScript.</p>
<h2>The spec came first, and the summary failed</h2>
<p>The spec is a set of JSON files. <code>screens.json</code> holds the 72 screens: per screen its route, what it reads, what it calls, and every transition with its gesture and motion. <code>decisions.json</code> holds every settled decision with the reasoning behind it. <code>motion.json</code> is 157 KB of animations researched from Instagram&#39;s own app.</p>
<p>The build prompt for the second attempt opens with one instruction, in bold: read the specification first, all of it, before writing any code. The sections after it are, in its own words, &quot;a map to the requirements&quot;, not the requirements. Where the prompt and the spec disagree, the spec wins.</p>
<p>The same prompt lists two holes the first attempt had, both invisible when you read the SQL and both found only by sending the request an attacker would send to the live API:</p>
<ul>
<li><code>revoke execute ... from anon</code> does nothing, because Postgres grants EXECUTE to <code>PUBLIC</code> and <code>anon</code> inherits it.</li>
<li>A table-level <code>revoke select ... from authenticated</code> silently disables every RLS policy on that table.</li>
</ul>
<p>So the working rule became: verify from the outside, with only the public key. Reading a policy is not the same as testing it.</p>
<h2>The backend is Instagram, the frontend is the game</h2>
<p>This is the decision everything else hangs on. The backend stores social data: players, follows, likes, comments, messages, plays. For each game it stores one string, <code>bundle_path</code>. It does not know what Queens is.</p>
<p>A post is a row with a <code>puzzle</code> column the backend never looks inside:</p>
<pre><code class="language-sql">create table if not exists public.posts (
  post_id       uuid primary key default gen_random_uuid(),
  game_id       uuid not null references public.games(game_id) on delete cascade,
  puzzle        jsonb not null,            -- opaque here; only the game reads it
  post_sequence int  not null,             -- this game&#39;s nth puzzle
  ...
);
</code></pre>
<p>Today <code>puzzle</code> is usually a seed. The board it draws does not exist until a phone imports the game&#39;s bundle and asks for it.</p>
<p>A game is written once, in <code>games/src/&lt;game&gt;/index.js</code>, and each difficulty is a two-line entry file:</p>
<pre><code class="language-js">const diff = &#39;hard&#39;
export default (await import(&#39;../../src/queens/index.js&#39;)).make(diff)
</code></pre>
<p>The database stores the relative path (<code>/games/queens/hard.js</code>) and the app loads it with a dynamic <code>import()</code> when a post needs it. Adding a game adds a row. No app update, no store review.</p>
<p>On Android, the JavaScript updates itself over the air: a signed manifest, a staged rollout by percentage, and never mid-session. A new bundle applies the next time the app goes to the background, not while someone is playing. That created one edge case. Posts come from the database the moment a game goes live, but a phone can still be on last week&#39;s bundle. So the app tries its own copy of the game first, and if it has none, fetches the same file from the server.</p>
<h2>A board has to come out the same everywhere</h2>
<p>If a post is a seed, two friends only play the same puzzle if the seed draws the same board on an iPhone, an Android phone, a laptop and the server. The shared random package opens with a list of what is banned in anything that decides a board:</p>
<ul>
<li><code>Math.random</code>, <code>Date.now</code>, <code>performance.now</code>. Time goes into the move log and nowhere else.</li>
<li><code>Math.sin</code>, <code>cos</code>, <code>exp</code>, <code>log</code>, <code>pow</code>. Their precision is implementation-defined and differs between V8, JavaScriptCore and SpiderMonkey. Integers, <code>Math.imul</code>, <code>|0</code> and <code>&gt;&gt;&gt;0</code> are exact everywhere.</li>
<li><code>sort(() =&gt; rng() - 0.5)</code>. It is not a real shuffle, and it is not stable across engines.</li>
<li><code>localeCompare</code>, <code>Intl</code>, <code>toLocaleString</code> in game logic.</li>
</ul>
<p>Randomness comes from one place: the seed string is hashed with cyrb128 into four 32-bit integers, which seed sfc32. Both use integer operations only.</p>
<p>A golden file keeps it honest. It records the boards a fixed set of seeds draw, and it is committed. If someone changes a generator, the test fails, because every past puzzle of that game would now redraw differently.</p>
<p>Queens is the exception. A Queens board has to be solvable without guessing, which means generating candidates and rejecting the ones a person could only guess at. The rejection test is a backtracking solution count, run thousands of times. That takes about a second: fine once on a server, not fine in a feed scrolling past thirty previews. So Queens boards are generated once, offline, and the post carries the finished grid, with its seed alongside so any board can be traced back to what made it. A few other games are published the same way.</p>
<h2>A time on a post means the board was solved</h2>
<p>Here is the whole path:</p>
<pre><code>posts row: game_id + puzzle (seed or baked board)
   |
   +--&gt; phone:    import(bundle_path) -&gt; build(puzzle) -&gt; play -&gt; move log
   |                                                                |
   +--&gt; verifier: same rules.js -&gt; replay(puzzle, moves) &lt;----------+
                        |
                        +--&gt; server&#39;s own stats -&gt; time on the post
</code></pre>
<p>Every tap goes into a move log with a timestamp. For Queens it looks like this:</p>
<pre><code>q34@2100;d05@2600;D4043@3100
</code></pre>
<p>A crown at row 3, column 4 at 2.1 s, a dot at row 0, column 5, then one painted run from (4,0) to (4,3). A painted run is one entry, not four. Written as four dots sharing a millisecond, an honest player would look like a script to the timing check.</p>
<p>When you finish, the phone sends the post id and the move log. The verifier loads the puzzle, picks the game&#39;s rules module, and replays the log from an empty board:</p>
<pre><code class="language-js">const run = game.rules.replay(ctx.puzzle, moves, game.difficulty);

if (run.illegal || !run.completed) {
  return json({ error: &#39;the moves do not replay to an end&#39;, illegal: !!run.illegal }, 422);
}

// the client&#39;s numbers are ignored
const elapsed = Date.now() - Date.parse(ctx.started_at);
const plausible =
     run.stats.time &lt;= elapsed + 2000
  &amp;&amp; run.minMoveGapMs &gt;= game.rules.humanFloorMs
  &amp;&amp; (ctx.attempts &lt; 50 || !ctx.p10_ms || run.stats.time &gt;= ctx.p10_ms * 0.25);
</code></pre>
<p>The <code>rules.js</code> it runs is the same file the game imports in the browser. No DOM, no clock, no <code>Math.random</code>. One copy of the rules, run by both sides.</p>
<p>A few details make it hold:</p>
<ul>
<li><strong>The phone&#39;s numbers are thrown away.</strong> Time, moves, mistakes and hints all come from the replay. A modified app can lie about its time, and the lie never reaches the board.</li>
<li><strong>The server&#39;s clock starts the game.</strong> The play row is created by the first move, with the server&#39;s timestamp. A claimed time can&#39;t be longer than the time that has actually passed, plus 2 seconds.</li>
<li><strong>Nobody taps forty times a second.</strong> Each game sets a floor for the gap between moves. For Queens it is 90 ms.</li>
<li><strong>Implausible is kept, not shown.</strong> A finish that fails those checks is recorded with <code>flagged = true</code>. The player sees their own result. Nobody else sees a number the server did not believe: no story, no &quot;beat you&quot; notification, no place on the post.</li>
<li><strong>The server is the only way to finish.</strong> The <code>plays</code> table&#39;s update policy refuses <code>is_completed</code>, and the function that records a finish is granted to the service role alone. A browser can save where it got to. It cannot say it arrived.</li>
</ul>
<p>The verifier runs as a Cloudflare Pages Function and talks to Postgres through Hyperdrive, so the database credential never leaves Cloudflare. A Worker can&#39;t evaluate code it downloads, so the rules for all 41 games are imported statically and picked by <code>bundle_path</code>, the same path the phone used to choose its copy.</p>
<p>That static list failed once. Nineteen games were carried over from an older project of mine and nobody added them to the verifier&#39;s list. The app played those puzzles all the way to the last move, then the server answered 501 and refused to record the finish. The only test that noticed was the one that plays a real board to its end. Now a test reads the verifier&#39;s imports and fails the build when any folder under <code>games/src</code> is missing.</p>
<p>There was one more bug, and it was in the database write. Passing <code>${JSON.stringify(stats)}::jsonb</code> to the Postgres driver looks right. The driver already serialises a string as a JSON string when the target is <code>jsonb</code>, so Postgres stored a string that contained JSON, not an object. <code>-&gt;&gt; &#39;time&#39;</code> on that is null, and every recorded finish had no time. The fix is two casts, <code>::text::jsonb</code>, which makes Postgres parse the text instead of wrapping it.</p>
<p>What replay buys is simple to say. A time on a post means a solved board. And nobody ever needs to see another person&#39;s board to trust it, which matters, because the board and the move log are the answer.</p>
<h2>Decisions that are product, not tech</h2>
<p>Some rules in the spec look technical and aren&#39;t:</p>
<ul>
<li><strong>No winning and no losing.</strong> A puzzle is completed or it is not. A wrong move counts a mistake and the board carries on. The UI never says &quot;won&quot;, &quot;lost&quot;, &quot;score&quot; or &quot;game over&quot;.</li>
<li><strong>Play is a button.</strong> Tapping a board in the feed does nothing. Play opens a full-screen player. Scrolling past a puzzle should never start one.</li>
<li><strong>Nobody sees another person&#39;s board</strong>, during play or after.</li>
<li><strong>Sign-in is required.</strong> No anonymous access, so every request is attributable to an account that can be suspended. Email sign-in is a code, not a magic link, because a magic link signs in whichever browser opened it.</li>
<li><strong>Two tables, never one.</strong> <code>players</code> are people. <code>games</code> are publishers run by the back office. A game cannot like, play, follow, be messaged or have a story. There is no <code>user_id</code> column anywhere, only <code>player_id</code> and <code>game_id</code>.</li>
</ul>
<p>Each of those is written in <code>decisions.json</code> with its reason, so the build didn&#39;t have to decide them again.</p>
<h2>How it was built</h2>
<p>The first commit is dated 2026-09-18. I aimed at three things: the best UI and UX I could get, the best board generation, and an architecture that ships a new game without an app update. The last one is where most of this post came from. A game is a bundle and a row, the phone draws the board from the post, and the server checks the result with the same code.</p>
<p>If you&#39;ve built something where the client reports a result, what do you replay or re-check on the server, and what do you simply trust?</p>
]]></content:encoded>
</item>
<item>
  <title>Headless Claude Code asks, exits, then resumes on the answer</title>
  <link>https://panth.vardayinitech.in/blog/questions-end-the-turn</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/questions-end-the-turn</guid>
  <pubDate>Mon, 05 Oct 2026 00:00:00 GMT</pubDate>
  <description>When my coding agent needs a decision, it asks in the ticket and ends its turn. No polling, no process left waiting. The answer starts claude -p --resume on the same session, with the values in the prompt.</description>
  <category>claude-code</category><category>ai</category><category>agents</category><category>open-source</category>
  <content:encoded><![CDATA[<p>When my coding agent needs a decision from me, it asks in the ticket and then stops. The process exits. My answer starts it again with <code>claude -p --resume</code> on the same session id, and the values I picked arrive as its next prompt.</p>
<p>The constraint: by default the orchestrator runs one agent at a time, and a run is capped at 240 minutes. A session sitting idle until I read my notifications would spend both.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. This is from <a href="https://github.com/Panth977/ticket-tracker">ticket-tracker</a>, my open-source board where headless Claude Code sessions work tickets next to people. The orchestrator is the <code>workspaces/</code> folder: one process per board, one Claude Code session per ticket.</p>
<h2>The loop</h2>
<p>The README draws it like this:</p>
<pre><code>To do  ──orch claims──▶ In progress ──agent reports &quot;review&quot;──▶ Review ──owner──▶ Done
                            │  ▲
           agent asks a     │  │ the answer (form or a comment) resumes
           question ────────▼  │ the same session
                          waiting
</code></pre>
<p>&quot;waiting&quot; is not a stage on the board. The ticket stays In progress. It is a phase the orchestrator keeps in its own state file, next to the session id.</p>
<h2>What the agent is told</h2>
<p>Every agent gets the same brief. Step 3 of it:</p>
<blockquote>
<p>If a real decision is the owner&#39;s to make (scope, design, anything destructive or outward-facing), call <code>ask_question</code> with <code>blocking: true</code> and real options. Then <strong>stop working and finish your turn</strong> with <code>status: &quot;waiting&quot;</code> and the <code>question_id</code>. Do not poll for the answer. The orch resumes this same session with the answer when it arrives.</p>
</blockquote>
<p><code>ask_question</code> puts a form card in the ticket thread: a title, optional context, and 1 to 10 fields (single choice, multiple choice, text, number, yes/no, date). A blocking question puts a &quot;Waiting for you&quot; badge on the ticket card and notifies whoever it is addressed to. When I submit it, the card locks and shows my answer.</p>
<p>The agent never decides on its own that it is finished. Its last message is structured output, enforced with <code>--json-schema</code>:</p>
<pre><code class="language-js">const OUTCOME_SCHEMA = {
  type: &#39;object&#39;,
  properties: {
    status: { type: &#39;string&#39;, enum: [&#39;review&#39;, &#39;waiting&#39;, &#39;blocked&#39;] },
    summary: { type: &#39;string&#39; },
    question_id: { type: &#39;string&#39; },
  },
  required: [&#39;status&#39;, &#39;summary&#39;],
};
</code></pre>
<p>Three ways to end a turn, and each one means something different to the orchestrator.</p>
<table>
<thead>
<tr>
<th>status</th>
<th>What the orch does next</th>
</tr>
</thead>
<tbody><tr>
<td><code>review</code></td>
<td>Moves the ticket to Review and stops.</td>
</tr>
<tr>
<td><code>waiting</code></td>
<td>Heartbeat &quot;Waiting for an answer&quot;, phase <code>waiting</code>, keeps the <code>question_id</code>. Nothing runs until a person answers or comments.</td>
</tr>
<tr>
<td><code>blocked</code></td>
<td>Carries on by itself, up to 3 times (<code>autoResume</code>), then posts on the ticket that it has stopped.</td>
</tr>
</tbody></table>
<p>The split between the last two matters. <code>waiting</code> means a person is needed. <code>blocked</code> on a long ticket almost always means the turn ran out of room, so the orchestrator resumes it without asking anyone, a bounded number of times.</p>
<h2>Why the session doesn&#39;t wait</h2>
<p>Each run is one <code>claude -p</code> process. When it exits, the orchestrator posts a receipt with what the turn cost and marks the slot free, so the next ticket in To do can start. With <code>maxAgents: 1</code>, a session waiting for me would hold the only slot. Every other ticket on the board would wait behind my inbox.</p>
<p>There is a second reason in the brief. The session is headless, so when a turn ends the process exits and takes its background tasks with it. A turn either finishes its work or ends cleanly. There is no half-alive process in between for me to find later.</p>
<p>And nothing polls. The agent is told not to. The orchestrator doesn&#39;t sit on a timer either: it wakes on the board&#39;s revision, and the answer reaches it as an event.</p>
<h2>How the answer gets back in</h2>
<p>When I submit the form, the tracker puts a <code>question_answered</code> event in the agent&#39;s inbox. The orchestrator drains its inbox, sees the event for a ticket in <code>waiting</code>, and resumes the session with a prompt built from my answer:</p>
<pre><code class="language-js">answered: (t, q) =&gt; `The owner answered your question &quot;${q.title}&quot; on ${t.key}.

Values: ${JSON.stringify(q.answer?.values ?? q.values ?? {})}
Comment: …

Carry on with the work.`,
</code></pre>
<p>The resume itself is one flag. If the session file for that id still exists, the orchestrator passes <code>--resume &lt;id&gt;</code>; if not, it starts a fresh session and resets its cost to zero.</p>
<pre><code class="language-js">...(resume ? [&#39;--resume&#39;, sessionId] : [&#39;--session-id&#39;, sessionId]),
</code></pre>
<p>The agent picks up with its whole context: the files it read, its plan, the question it asked. It doesn&#39;t re-read the repo to work out where it was.</p>
<p>A few edges are handled on the way:</p>
<ul>
<li><strong>A comment instead of the form.</strong> If I reply in the thread rather than filling in the card, that also resumes the session, with my message in the prompt.</li>
<li><strong>No slot free.</strong> If another ticket is running, the ticket&#39;s status line says &quot;Got your reply · queued until an agent is free&quot;, so an answered question never looks ignored.</li>
<li><strong>Cancelled or expired.</strong> The prompt says so, and tells the agent to read the thread, then go ahead with the most conservative option or ask again.</li>
<li><strong>A missed event.</strong> Every fourth loop, a ticket still waiting has its question fetched directly, in case the event was lost.</li>
<li><strong>The answer is progress.</strong> It resets the ticket&#39;s automatic-continuation count to zero, and so does any comment from me.</li>
</ul>
<h2>Cost stays honest across resumes</h2>
<p>Claude Code reports <code>total_cost_usd</code> cumulatively across <code>--resume</code>. In a session that asked two questions, summing the reported totals would count the first turn&#39;s cost three times. So the receipt for a turn is the difference from the session&#39;s last reported total, and the session&#39;s running total lives in the orchestrator&#39;s state.</p>
<h2>What it costs me</h2>
<p>A question costs no compute while I&#39;m away: there is no process to keep alive. The price is latency: the ticket sits until I answer, and nothing else picks it up. I&#39;m fine with that. The questions an agent asks are the decisions I said were mine.</p>
<p>How does your agent setup handle a question it can&#39;t answer itself: does the session wait, or does it end the turn and come back?</p>
]]></content:encoded>
</item>
<item>
  <title>52 commands, 3 doors: the app, REST and MCP share one write path</title>
  <link>https://panth.vardayinitech.in/blog/writes-through-commands</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/writes-through-commands</guid>
  <pubDate>Sat, 03 Oct 2026 00:00:00 GMT</pubDate>
  <description>My ticket tracker has three ways in: the app, a REST API and an MCP server. All three call the same 52 commands, and Firestore rules deny every other write. An agent with a token can do nothing a person couldn&apos;t, and the price is 150 to 400 ms a write.</description>
  <category>firebase</category><category>mcp</category><category>architecture</category><category>open-source</category>
  <content:encoded><![CDATA[<p>My ticket tracker has three ways in: the web app, a REST API and an MCP server. Every write from all three goes through the same 52 commands, and the Firestore rules deny everything else. It costs 150 to 400 ms a write where a direct client write takes about 50 ms.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. The tracker is <a href="https://github.com/Panth977/ticket-tracker">ticket-tracker</a>, my open-source board where coding agents work tickets next to people. This is the first decision in its plan, and the one that would cost the most to reverse.</p>
<h2>The Firebase default I turned down</h2>
<p>The usual Firebase shape is that clients write Firestore directly, security rules decide who may, and triggers do the side effects.</p>
<p>I kept half of it. Clients still read Firestore directly. Every write is a <code>POST /api/{command}</code>, and the rules deny writes except for a person&#39;s own pointers.</p>
<p>Two things decided it.</p>
<p>Nearly every write has a second document to keep in step: the key counter, the activity log, the other end of a link, notifications, webhooks.</p>
<p>And rules can&#39;t carry the permissions. Try writing this one as a rule somebody can maintain: a commenter may change a ticket&#39;s stage only between the stages in their grant, and only on tickets assigned to them.</p>
<h2>The reason that mattered more</h2>
<p>REST and MCP need the same logic anyway. With client writes, every rule would exist twice, once in <code>firestore.rules</code> and once in the API, and the two would drift.</p>
<p>So a command is defined once, as a spec in the shared package:</p>
<pre><code class="language-ts">export const messagePost = defineCommand({
  name: &#39;messagePost&#39;,
  scopes: [&#39;comments:write&#39;],
  permission: &quot;can(comment); ticket state == &#39;active&#39;&quot;,
  errors: [&#39;forbidden&#39;, &#39;not_found&#39;, &#39;conflict&#39;, &#39;too_large&#39;],
  req: z.object({ /* ticket, body, replyTo, attachments … */ }),
  res: OkResSchema,
});
</code></pre>
<p>(The <code>req</code> is shortened here.) The registry holds 52 of these. The backend parses requests with the spec, and the frontend&#39;s <code>command(name, input)</code> is typed by the same map.</p>
<h2>Three doors, one runner</h2>
<table>
<thead>
<tr>
<th>Door</th>
<th>Who comes in</th>
<th>How it reaches a command</th>
</tr>
</thead>
<tbody><tr>
<td><code>/api/{command}</code></td>
<td>the app, with a sign-in token</td>
<td>the body is the command&#39;s request</td>
</tr>
<tr>
<td><code>/v1</code> REST</td>
<td>an orchestrator or agent, with a board token or OAuth</td>
<td>route, scope gate, names to ids, then the same command</td>
</tr>
<tr>
<td><code>/mcp</code></td>
<td>Claude and other MCP clients</td>
<td>each tool parses its input, then calls the same commands</td>
</tr>
</tbody></table>
<p>All three end in one runner, and it does the same things in the same order:</p>
<pre><code>registry lookup → scope narrowing → parse the request → idempotency claim
  → handler → parse the response → bump the board&#39;s revision → answer
</code></pre>
<p>The doors are routes in one HTTPS function. The alternative was a function per endpoint, and sixty functions means sixty cold starts, sixty deploy units and sixty minimum-instance bills. One service stays warm on the app&#39;s traffic alone, so REST and MCP calls land on warm instances.</p>
<h2>Agents can do nothing people can&#39;t</h2>
<p>A token only narrows. What it may do is its scopes intersected with <code>can()</code> for the principal it acts as, a person or one of their agents. The scope check at the door only answers early. The command decides.</p>
<p>A command that lists no scopes is app-only, and no token reaches it. There is no API-only write path that could drift from the app. And there is deliberately no MCP tool that deletes a ticket.</p>
<p>Every command also runs with who is acting and through which door (<code>app</code>, <code>api</code> or <code>mcp</code>), so a change made with an agent&#39;s token is attributed to that agent and to the token.</p>
<h2>What the choke point bought later</h2>
<p>Because every write passes through one runner, it became the place to announce that something on a board changed. After a command succeeds, the runner makes one small Realtime Database write to the board&#39;s revision. The app and the SDK listen to that node and ask for the delta, not poll for it. A replayed idempotent request bumps nothing, because nothing changed.</p>
<p>That came later, after I measured the bill and found the orchestrator&#39;s polling was 88% of all requests. It went into one place because the place existed.</p>
<h2>The cost</h2>
<p>A write takes about 150 to 400 ms, not about 50. Optimistic updates hide it in the app. Cold starts are covered by <code>minInstances: 1</code> on the function in production.</p>
<p>That is the whole price: a slower write, hidden by optimistic updates, for one copy of every rule.</p>
<p>If your app has a UI, an API and an MCP server, do all three share one write path, or does each one have its own copy of the rules?</p>
]]></content:encoded>
</item>
<item>
  <title>Agent health from a 60-second heartbeat: derived, never stored</title>
  <link>https://panth.vardayinitech.in/blog/agent-heartbeat-derived-health</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/agent-heartbeat-derived-health</guid>
  <pubDate>Fri, 02 Oct 2026 00:00:00 GMT</pubDate>
  <description>In my ticket tracker an agent&apos;s status is never stored as alive or dead. The orchestrator beats once a minute, and every screen works out the dot from the age of the last beat, so a crashed agent turns red with no write from anyone.</description>
  <category>ai-agents</category><category>firebase</category><category>architecture</category><category>open-source</category>
  <content:encoded><![CDATA[<p>A coding agent that crashes can&#39;t report that it crashed. In my ticket tracker, the orchestrator sends a heartbeat every 60 seconds while an agent works, and a &quot;working&quot; status whose last beat is more than 75 seconds old shows as red on every screen, with no write from anyone.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. The tracker is <a href="https://github.com/Panth977/ticket-tracker">ticket-tracker</a>, my open-source board where each ticket runs its own headless Claude Code session. This post is about one small piece of it: how the board knows an agent is alive.</p>
<h2>The status that lies</h2>
<p>The obvious design stores a status on the ticket: <code>working</code>, then <code>done</code>. The agent writes <code>working</code> when it starts and <code>done</code> when it finishes.</p>
<p>If the process dies in between, nothing writes anything. The ticket says <code>working</code> for as long as nobody looks into it. A stored status is only as true as the last thing that managed to write it, and a dead process writes nothing.</p>
<h2>What the beat carries</h2>
<p>The orchestrator sends the beat, not the agent. It is one call:</p>
<pre><code>POST /v1/heartbeat
{ ticket?: KEY,            // omit = an agent-level beat, with no ticket
  state: &#39;working&#39; | &#39;idle&#39; | &#39;done&#39; | &#39;error&#39;,
  message?: string,        // &quot;Running tests (3/12)&quot;, up to 200 characters
  progress?: number }      // 0 to 1
</code></pre>
<p>It goes out every minute while the agent works and once more when the work ends. The message comes from the agent&#39;s task list: the orchestrator reads the list on every fifth beat and repeats the last message on the four in between.</p>
<p>A beat never touches the ticket document. It has its own small record per agent and ticket, so a beat a minute doesn&#39;t retrigger the ticket&#39;s search indexing or re-render everything that listens to the ticket.</p>
<h2>Health is computed where it is read</h2>
<p>The stored state has four values. The health on screen has six, and the extra ones come from the clock:</p>
<pre><code class="language-ts">export const HEARTBEAT_INTERVAL_MS = 60_000;
export const HEARTBEAT_STALE_MS = 75_000;

export function deriveAgentHealth(status, now, staleAfterMs = HEARTBEAT_STALE_MS) {
  if (!status) return &#39;none&#39;;
  if (status.state !== &#39;working&#39;) return status.state;
  return now - status.lastBeatAt &gt; staleAfterMs ? &#39;stale&#39; : &#39;working&#39;;
}
</code></pre>
<p>75 seconds is one missed beat plus a little slack.</p>
<table>
<thead>
<tr>
<th>Stored state</th>
<th>Last beat</th>
<th>Shown as</th>
</tr>
</thead>
<tbody><tr>
<td>working</td>
<td>75 s ago or less</td>
<td>green pulsing dot, &quot;Working · Running tests (3/12)&quot;</td>
</tr>
<tr>
<td>working</td>
<td>more than 75 s ago</td>
<td>red, &quot;No signal for 3 min&quot;</td>
</tr>
<tr>
<td>idle</td>
<td>any</td>
<td>yellow, &quot;Idle · waiting for an answer&quot;</td>
</tr>
<tr>
<td>done</td>
<td>any</td>
<td>grey, &quot;Finished · 10:42&quot;</td>
</tr>
<tr>
<td>error</td>
<td>any</td>
<td>red, &quot;Stopped with an error&quot; and the message</td>
</tr>
<tr>
<td>none yet</td>
<td></td>
<td>nothing</td>
</tr>
</tbody></table>
<p>Only <code>working</code> can go stale. Idle, done and error are statements that stay true until the next beat. <code>working</code> is a claim that has to be renewed.</p>
<p>This one function is in the shared package. The card, the ticket header, the People &amp; roles page and the Agents page all call it, so they can&#39;t disagree about an agent. When several agents are on one ticket, the card shows the loudest: error first, then no signal, then working, idle and finished.</p>
<h2>The 15-second clock</h2>
<p>A derived value only changes when something recomputes it. If no new beat arrives, no data arrives either, so the screen needs its own clock:</p>
<pre><code class="language-ts">export const HEALTH_TICK_MS = 15_000;

export const healthClock = readable(Date.now(), (set) =&gt; {
  const id = setInterval(() =&gt; set(Date.now()), HEALTH_TICK_MS);
  return () =&gt; clearInterval(id);
});
</code></pre>
<p>15 seconds is a quarter of the beat interval, so the longest a dot can be wrong is 15 seconds. It is one interval shared by every dot on screen, and it stops when the last subscriber goes.</p>
<h2>What it cost, and what I changed</h2>
<p>The first version kept each beat stream as a Firestore document. I measured the project&#39;s bill from 23 to 26 September 2026 and the heartbeat was on it twice:</p>
<ul>
<li>1,440 writes per agent per day, each a Firestore transaction through a function, followed by one read in every open tab.</li>
<li>A scheduled sweep that ran every minute to notice silence: 4,261 runs in three days.</li>
</ul>
<p>Two changes came out of that.</p>
<p>Beats moved to the Realtime Database, which bills bandwidth and not operations. A beat is now about 100 bytes. The shape everything above the database reads stayed the same, so no screen changed.</p>
<p>The sweep stopped looking for silence. Noticing silence never needed a server, because staleness is <code>now - lastBeatAt</code> wherever the beat is read. The sweep now runs every three minutes and does the one thing that does need a server: if a working agent has been silent for five minutes, it tells the agent&#39;s owner, once per silence, because nobody may have the board open. The next beat re-arms it.</p>
<h2>If you are building one</h2>
<p>Store the facts: the last state the agent claimed and when it claimed it. Derive the judgement, alive or not, at read time, in one function every screen shares. Then give the screen a clock, or the judgement never updates.</p>
<p>How does your agent setup tell you a run died: a timeout on the server, or something you work out from the last thing it said?</p>
]]></content:encoded>
</item>
<item>
  <title>One stack per ticket: why our coding agent works on a DB copy</title>
  <link>https://panth.vardayinitech.in/blog/one-stack-per-ticket</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/one-stack-per-ticket</guid>
  <pubDate>Wed, 30 Sep 2026 00:00:00 GMT</pubDate>
  <description>Every ticket on our board gets a branch in every repo, its own services, a copy of the dev database and a preview URL. It started as a fix for branch mistakes. It is also what keeps a coding agent&apos;s worst case small.</description>
  <category>ai-agents</category><category>devops</category><category>architecture</category><category>sqlite</category>
  <content:encoded><![CDATA[<p>Every ticket on our board gets its own stack: a branch in every repo, its own services, its own copy of the dev database and its own preview URL. We built it because coding agents made my developers faster, and the mistakes moved out of the code and into the branches around it.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom, where Envizom is the platform. This is why our agent pipeline isolates per ticket, and what the isolation buys beyond running tasks in parallel.</p>
<h2>Where the mistakes were</h2>
<p>With an agent, one developer can keep three tasks in flight. That holds while all three go into the same branch and the same release. Once they are due in three different releases, the developer juggles repos and branches and has to remember which change belongs to which PR.</p>
<p>The development was usually fine. The mistakes were a change on the wrong branch, or a fix that only worked on one machine. As agents got faster I saw more &quot;works on my machine&quot; on my team, and client expectations kept going up at the same time.</p>
<p>What was missing was a sandbox you could fork for one task and turn into a PR when it was done.</p>
<h2>What a ticket gets</h2>
<p>When a developer drags a ticket to Approved, orch, our orchestrator, provisions a workspace for it:</p>
<table>
<thead>
<tr>
<th>Part</th>
<th>What the ticket gets</th>
</tr>
</thead>
<tbody><tr>
<td>Code</td>
<td>a fresh branch in every repo, named after the ticket</td>
</tr>
<tr>
<td>Services</td>
<td>its own backend, drivers, simulator and frontend</td>
</tr>
<tr>
<td>Data</td>
<td>a copy of the dev database</td>
</tr>
<tr>
<td>Access</td>
<td>a private preview URL, with an HTTPS certificate issued on demand</td>
</tr>
</tbody></table>
<p>An agent does the work inside that workspace. The developer talks to it only through the ticket thread, tests the preview, and drags the ticket to Git Push when it is right. Until then, nothing the ticket does reaches the shared dev branch.</p>
<h2>Isolation is the safety decision</h2>
<p>A stack per ticket looks like a way to run things in parallel. That is half of it. The other half is safety.</p>
<p>An agent that runs a bad migration or deletes the wrong rows does it to the ticket&#39;s copy of the database. An agent that breaks the backend breaks the ticket&#39;s backend. Nobody else&#39;s work is touched, because nobody else&#39;s work is in that box.</p>
<p>The shared environment is reached only at Git Push, and that step is a script, not the agent. Orch commits the ticket branches, merges dev into them, opens and merges the PRs, waits for CI, rolls the new images onto the dev VM and runs health checks. If the VM is unhealthy afterwards, orch rolls it back to the previous images, reverts the merges and tells the thread what happened.</p>
<p>All of it stops at dev. Production goes through a separate path where people make the call.</p>
<h2>Independent timelines</h2>
<p>The second thing isolation gives us is time. A ticket can sit in QA for a week while three others ship. Its branch, its data and its thread wait for it, and nothing else waits on it. Three tickets due in three releases are three separate stacks, and each one ends in its own PR.</p>
<p>It is also why the audit trail holds together. What was asked, what was built, which preview was tested and what feedback went back all sit on one ticket, not mixed with another ticket&#39;s work.</p>
<h2>What made the copies cheap</h2>
<p>A database copy per ticket only works if copying is cheap. In Envizom V3, which we are rebuilding from scratch, the backend runs in dev without a single extra database credential. Every store has a dev driver:</p>
<table>
<thead>
<tr>
<th>Store</th>
<th>Prod</th>
<th>Dev</th>
</tr>
</thead>
<tbody><tr>
<td>Main DB</td>
<td>Postgres</td>
<td>SQLite</td>
</tr>
<tr>
<td>Assets</td>
<td>S3</td>
<td>SQLite</td>
</tr>
<tr>
<td>Device data</td>
<td>a time-series database</td>
<td>SQLite</td>
</tr>
<tr>
<td>Pub/sub</td>
<td>MQTT</td>
<td>a service built into the Go backend</td>
</tr>
<tr>
<td>Cache</td>
<td>Redis</td>
<td>SQLite</td>
</tr>
</tbody></table>
<p>SQLite runs inside the Go process, so the dev kit needs no extra containers, and copying a database means copying a few files. That change made our sandboxes about 10x cheaper and faster than sandboxes with full databases.</p>
<p>The dev VM that holds the devboxes and the ticket stacks costs about ₹4–6k a month and handles six tickets in parallel.</p>
<h2>If you are building one</h2>
<p>Start with isolation, not with the agent. If a task can&#39;t run in its own branch, with its own services and its own data, nothing else in the pipeline is safe. Then put the agent inside the box, and keep every step that leaves the box scripted.</p>
<p>If your team runs a sandbox per branch or per ticket, which part was hardest to copy: the code, the services or the data?</p>
]]></content:encoded>
</item>
<item>
  <title>$402 on one ticket: cost per turn in headless Claude Code</title>
  <link>https://panth.vardayinitech.in/blog/cost-per-turn-headless-claude-code</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/cost-per-turn-headless-claude-code</guid>
  <pubDate>Mon, 28 Sep 2026 00:00:00 GMT</pubDate>
  <description>One ticket reached $402 over nine headless Claude Code runs and nothing in the app said so. The catch when you fix that: total_cost_usd is cumulative across --resume, so run 8 made one API call with zero output tokens and reported the same $392.83 as run 7.</description>
  <category>claude-code</category><category>ai-agents</category><category>llm</category><category>finops</category>
  <content:encoded><![CDATA[<p>One ticket on my board reached $402 over nine agent runs, and nothing in the app said so. Putting the cost on every turn has one catch: Claude Code&#39;s <code>total_cost_usd</code> is cumulative across <code>--resume</code>. Run 8 made one API call with zero output tokens and reported the same $392.83 as run 7.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. This is from <a href="https://github.com/Panth977/ticket-tracker">ticket-tracker</a>, the tracker I open-sourced, where AI agents are board members and a small orchestrator turns each ticket into a headless Claude Code session.</p>
<h2>How a ticket becomes a session</h2>
<p>The orchestrator (<code>workspaces/lib/orch.mjs</code>, about 600 lines, zero dependencies) runs one headless Claude Code session per ticket. Each time the agent has something to do, it starts <code>claude -p</code> once: a fresh ticket, rework, an answer to a question, a new comment, or carrying on by itself. That is one turn. The process exits, and the last line of its <code>stream-json</code> output is a result line.</p>
<p>The first turn creates the session with <code>--session-id</code>. Every later turn on the same ticket continues it:</p>
<pre><code class="language-js">const resume = e.session_id &amp;&amp; fs.existsSync(sessionFile(e.session_id));
const sessionId = resume ? e.session_id : randomUUID();

const args = [
  &#39;-p&#39;, prompt,
  &#39;--output-format&#39;, &#39;stream-json&#39;, &#39;--verbose&#39;,
  ...(resume ? [&#39;--resume&#39;, sessionId] : [&#39;--session-id&#39;, sessionId]),
  // ...
];
</code></pre>
<p>The session keeps its history, so the agent picks up where it stopped. That is the point of resuming. It is also why the cost number misleads.</p>
<h2>The trap</h2>
<p>The result line carries <code>total_cost_usd</code>, <code>usage</code> (input, output and cache tokens), <code>num_turns</code> and <code>modelUsage</code>. Before the change, the orchestrator already read <code>total_cost_usd</code> off every result line and wrote it to a local log. Nothing in the app showed it.</p>
<p>Putting the number on the ticket looks like a one-liner: post <code>total_cost_usd</code> when the run ends. It isn&#39;t, because that field is the session&#39;s running total, not the run&#39;s. Ticket OCZ-5 shows it plainly. Run 8 did almost nothing, one API call and zero output tokens, and reported $392.83. So did run 7.</p>
<p>Read naively, that line says run 8 cost $392.83. Add those lines up and every earlier run is counted again on each later turn.</p>
<h2>The fix: keep the last total, post the difference</h2>
<p>The orchestrator keeps per-ticket state in a JSON file. Two fields handle cost: <code>session_usd</code>, the last total that ticket&#39;s session reported, and <code>session_usage</code>, the last token counts. When a turn ends:</p>
<pre><code class="language-js">const prevUsd = e.session_usd ?? 0;
const costUsd = Math.max(0, r.total_cost_usd - prevUsd);

const now = {
  input: u.input_tokens ?? 0,
  output: u.output_tokens ?? 0,
  cache_read: u.cache_read_input_tokens ?? 0,
  cache_write: u.cache_creation_input_tokens ?? 0,
};
const prev = e.session_usage ?? { input: 0, output: 0, cache_read: 0, cache_write: 0 };
const usage = Object.fromEntries(Object.keys(now).map((k) =&gt; [k, Math.max(0, now[k] - prev[k])]));

e.session_usd = r.total_cost_usd;
e.session_usage = now;
saveState();
</code></pre>
<p>The turn&#39;s cost is the new total minus the old one, clamped at zero. The token counts get the same treatment. Then the new total is saved for the next turn.</p>
<p>Three cases decide whether the numbers stay honest:</p>
<table>
<thead>
<tr>
<th>Case</th>
<th>What the orchestrator does</th>
</tr>
</thead>
<tbody><tr>
<td>New session (first turn, or the session file is gone)</td>
<td>Starts a fresh session id and resets <code>session_usd</code> to 0 before the run</td>
</tr>
<tr>
<td>Resumed session</td>
<td>Subtracts the last reported total</td>
</tr>
<tr>
<td>Run dies without a result line</td>
<td>Posts no receipt. Its spend shows up in the next turn&#39;s difference, which is still correct: it was spent on this ticket</td>
</tr>
</tbody></table>
<p>The resume check matters more than it looks. The orchestrator only resumes if the session&#39;s <code>.jsonl</code> file still exists under <code>~/.claude/projects/</code>. If it doesn&#39;t, the turn starts a new session, and a new session counts from zero. Subtracting the old total from it would wipe that turn&#39;s cost.</p>
<h2>Where the number goes</h2>
<p>Money spent on a ticket is a fact about the ticket, so it goes where the ticket&#39;s other facts go. When a run ends, the orchestrator posts one message on the ticket, the turn receipt. In the design doc&#39;s example, the body reads:</p>
<pre><code class="language-plaintext">Turn 3 · review · $1.24 · 12 min
</code></pre>
<p>Beside the text it carries a <code>run</code> field: the turn number, the outcome (<code>review</code>, <code>waiting</code>, <code>blocked</code>, <code>failed</code>, <code>stopped</code> or <code>timeout</code>), <code>cost_usd</code> for this turn, <code>session_usd</code> as Claude Code reported it, duration, API calls, model and the token deltas. Both numbers are kept on purpose. The turn cost is what the person reading the thread cares about. The session total is what you check the arithmetic against.</p>
<p>The server stores the receipt on the message and, in the same write, adds it to the ticket&#39;s cost, the board&#39;s cost and the day&#39;s row the Analytics view draws. So the card shows the spend next to its other signals, and the board shows cost per day and which tickets cost the most.</p>
<p>Only the orchestrator posts receipts. The app&#39;s comment box never does. Each receipt goes out with an <code>Idempotency-Key</code> made from the ticket and the run number, so a retried request doesn&#39;t count a turn twice.</p>
<h2>What changed</h2>
<p>Cost per turn in the thread is a deliberate design choice, not a debug feature. The person who asked for the work sees what each turn of it cost, in the same thread where they asked.</p>
<p>The lesson for anyone running Claude Code headless: treat <code>total_cost_usd</code> as a meter reading, not a bill. Store the last reading per session and bill the difference.</p>
<p>If you run Claude Code from scripts or CI, where does the cost of a single run end up for you, and does it account for <code>--resume</code>?</p>
]]></content:encoded>
</item>
<item>
  <title>The ticket is the interface: how our developers ship without a laptop</title>
  <link>https://panth.vardayinitech.in/blog/ticket-is-the-interface</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/ticket-is-the-interface</guid>
  <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
  <description>How a small team turned its ticket tracker into the only developer interface: per-ticket stacks with their own DB copy and preview URL, an AI agent driven from the ticket thread, and a scripted orchestrator that merges, deploys and rolls back.</description>
  <category>devops</category><category>ai-agents</category><category>developer-experience</category><category>platform-engineering</category>
  <content:encoded><![CDATA[<div class="video"><iframe src="https://www.youtube-nocookie.com/embed/5IuE38UzhW4" title="The ticket is the interface: 2-minute walkthrough" loading="lazy" allowfullscreen allow="accelerometer; encrypted-media; picture-in-picture"></iframe></div><p>Neither I nor my developers need a laptop to ship code anymore. That sounds like a stunt, so here is the boring version: on our team a developer&#39;s working day happens inside a ticket thread, and the parts that make that possible are mostly scripts, not AI.</p>
<p>This post is the long version of the short video above. It covers what the pipeline does, how the pieces fit, what we deliberately kept dumb, and where we drew the line on automation.</p>
<h2>The problem we were actually solving</h2>
<p>I lead the software team at Oizom, an environmental monitoring company. Our platform is called Envizom, and it is several repos: hardware drivers, a simulator, a backend, a frontend. Getting a working local setup took a new developer days, and even for the rest of us &quot;does it work on your machine&quot; was a real question.</p>
<p>When we started using AI coding agents, the first thing we noticed was not the speed of the agent. It was the waiting. One developer, one agent, one task at a time, sitting there watching a terminal. The agent was fast. The developer was the bottleneck.</p>
<p>We wanted five things:</p>
<ol>
<li>A developer should be able to hand off a task and go do something else.</li>
<li>Every task should run in isolation, so an agent cannot break anyone else&#39;s work, and so features can live on different timelines. One ticket can sit in QA for a week while three others ship.</li>
<li>Nothing about &quot;we use agents&quot; should weaken the normal engineering process: branches, commits, pull requests, review, CI.</li>
<li>Every prompt should be stored with the work it produced, so we can review and coach how people ask for things.</li>
<li>The ticket should be the audit trail: what was asked, what was built, what was tested, what feedback was given, in one place.</li>
</ol>
<h2>The ticket is the interface</h2>
<p>We already had our own ticket tracker. So we made the ticket the only thing a developer touches.</p>
<p>A developer writes what they want in a ticket, in plain English, and drags it to <strong>Approved</strong>. That is the whole spec. There is no IDE open, no terminal, no prompt window.</p>
<p>On our dev VM each developer has their own devbox: VS Code running in the browser, with our orchestrator inside it. We call it <strong>orch</strong>. Every developer is paired with their own agent, and their orch only picks up tickets assigned to that agent. So the board is shared, but the machines and the agents are not.</p>
<h2>Each ticket gets its own full stack</h2>
<p>When orch sees an approved ticket it provisions a workspace for it:</p>
<ul>
<li>a fresh branch in every repo, named after the ticket</li>
<li>its own backend, drivers, simulator and frontend, running in that workspace</li>
<li>a copy of the dev database, so the ticket can change data freely</li>
<li>a private preview URL for the ticket, with an HTTPS certificate issued on demand</li>
</ul>
<p>This is the part that made everything else possible. Because every ticket is fully isolated, a developer can have several tickets in flight at once, and agents cannot step on each other. If an agent does something silly to the database, it did it to a copy.</p>
<h2>An agent does the work, but only from the thread</h2>
<p>Inside that workspace an AI coding agent picks up the ticket. Orch gives it the ticket text and the workspace, and the agent does the actual engineering work.</p>
<p>The developer only ever talks to the agent through the ticket thread. When the agent is done it posts the preview link and moves the ticket to <strong>QAQC</strong>. The developer opens the preview, tests it, replies with feedback in the same thread, and moves the ticket back. The same agent, with the same context, picks up where it left off.</p>
<p>In the video I asked for a camera layout change on a device page. The agent shipped it, posted the preview, I replied &quot;also handle more than 4 cameras with a horizontal scroll, and set up 8 test cameras on this device&quot;, and it did both, including creating the test data in the ticket&#39;s own database copy.</p>
<p>Two of the design goals pay off here, and they are the ones people underestimate:</p>
<ul>
<li><strong>Every prompt is saved.</strong> The thread is the prompt history. I can read how each person on the team asks for things and coach them on it. That has been more useful than any prompting guide.</li>
<li><strong>The thread is the audit trail.</strong> What was asked, what was built, which preview was tested, what feedback was given. All in one place, attached to the ticket. Because each ticket has its own stack, that trail is also self-contained: a ticket can pause in QA for days without blocking anything else.</li>
</ul>
<h2>Shipping is a drag-and-drop</h2>
<p>When the developer is happy they drag the ticket to <strong>Git Push</strong>. From here on, no agent is involved. Orch:</p>
<ol>
<li>commits the work on the ticket branches</li>
<li>merges the current dev branch into them</li>
<li>opens a pull request in each affected repo</li>
<li>merges the pull requests</li>
<li>posts the PR and commit links back into the ticket thread</li>
<li>waits for CI</li>
<li>rolls the new images onto the dev VM</li>
<li>runs health checks against the VM</li>
</ol>
<p>If the VM is not healthy afterwards, orch rolls the VM back to the previous images and reverts the merges. On its own. Then it tells the thread what happened.</p>
<p>When it is healthy, the ticket moves to <strong>Completed</strong> and the developer gets a notification. In the video that is the whole ending: two notifications, and the change is live on dev.</p>
<h2>What is AI and what is not</h2>
<p>This is the part I am proudest of, and the part I think most teams get backwards.</p>
<p>Roughly 90% of this pipeline is plain scripted code. Watching the board, provisioning the stack, copying the database, issuing certificates, driving git, opening PRs, waiting on CI, deploying, health checks, rollback. None of that is done by an agent. It is deterministic, it is tested, and when it breaks I can read a log and fix it.</p>
<p>The agent does exactly one thing: the engineering work inside an isolated workspace, in response to a human&#39;s words in a ticket.</p>
<p>We tried the other way first, with the agent doing more of the operational steps. It was slower, more expensive, and worse at exactly the tasks that need to be boring. An agent deciding how to merge is not a feature. A script that always merges the same way is.</p>
<h2>What it gave us</h2>
<ul>
<li>One developer runs several tickets in parallel instead of babysitting one agent.</li>
<li>No local setup. New people are productive on day one. The laptop can be closed.</li>
<li>Every feature still gets its own branch, commits, PR and review. Nothing about our process got looser.</li>
<li>Every prompt the team writes is stored with the ticket.</li>
</ul>
<h2>Three deliberate choices</h2>
<p>People sometimes read these as gaps. They are decisions.</p>
<ul>
<li><strong>Cost is visible on every turn.</strong> Each agent turn posts its cost into the ticket thread. That is intentional. The person asking sees what the ask cost, right where they asked it, and it changes how they write the next one.</li>
<li><strong>QA is human.</strong> The developer tests the preview and decides whether the ticket moves forward. That step is where we check the thing that was built is the thing that was asked for. An agent grading its own work would remove the one review that matters most.</li>
<li><strong>This pipeline is for dev only.</strong> Rollback, auto-merge, VM updates: all of it stops at the dev environment. Production goes through a separate path where real people make the call, on a slower cadence. We do not want automation deciding what reaches production, and we do not want a fast feature list to become the goal.</li>
</ul>
<h2>What is next</h2>
<p>The one thing I keep looking at is the dev VM itself. The per-developer devboxes and the per-ticket stacks all run on one rented box. It costs about ₹4–6k a month and comfortably handles six tickets in parallel, which is fine at our size, but it is still one machine and it is still rented.</p>
<p>The plan is almost embarrassingly simple. We have a pile of laptops. Turn them into VMs. The whole point of this pipeline is that your main laptop can be closed while the work happens, so the machine doing the work might as well be an old laptop on a shelf in the office. A one-time purchase of in-house hardware will never cost what a cloud VM costs over a year, and it keeps the same isolation model: each developer&#39;s devbox on its own box.</p>
<p>We have not done this yet. When we do, I will write it up.</p>
<h2>If you want to build something similar</h2>
<p>Start with isolation, not with the agent. If every task cannot run in its own branch, its own services and its own data, none of the rest is safe. Then make the handoff surface something your team already uses. For us that was the ticket. Then, and only then, put an agent inside the box.</p>
<p>And keep the box scripted. The agent should be the smallest part of the system.</p>
<p>If you have questions, ask in the comments. I am collecting them for a follow-up post.</p>
<hr>
<p><em>I lead the software team at <a href="https://oizom.com">Oizom</a>, where Envizom is the platform that turns data from air-quality and environmental monitors in 80+ countries into something cities and industries can act on. The walkthrough video is on <a href="https://youtu.be/5IuE38UzhW4">YouTube</a>; find me on <a href="https://www.linkedin.com/in/panth-patel-447a88240/">LinkedIn</a>.</em></p>
]]></content:encoded>
</item>
<item>
  <title>1 million readings, 2 GB of RAM: Node.js typed arrays over JSON</title>
  <link>https://panth.vardayinitech.in/blog/1mil-datapoints-in-nodejs</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/1mil-datapoints-in-nodejs</guid>
  <pubDate>Tue, 22 Jul 2025 00:00:00 GMT</pubDate>
  <description>Our Oizom backend ran out of memory on a 2 GB droplet whenever a report touched a million sensor readings held as JSON objects. Two typed arrays and one class fixed it. The first version was 8x slower than JSON.parse; dropping sorted rows made the second one faster.</description>
  <category>node-js</category><category>performance</category><category>typed-arrays</category><category>arraybuffer</category><category>memory</category><category>video</category>
  <content:encoded><![CDATA[<p>A report query that touched a million sensor readings kept killing our Node.js backend on a 2 GB droplet. I replaced the million JSON objects with two typed arrays and one class. The first version never ran out of memory and was 8x slower than <code>JSON.parse</code>. The second was faster than <code>JSON.parse</code>, and memory stayed flat.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom, where we build air-quality monitoring. This is the written version of a four-part screen recording I made while building it. The videos are below if you would rather watch me get it wrong first.</p>
<div class="video"><iframe src="https://www.youtube-nocookie.com/embed/videoseries?list=PLeXF8QGCGNK7MrBOweoDSd6E1L_7upSPV" title="1 million+ data points in Node.js" loading="lazy" allowfullscreen allow="accelerometer; encrypted-media; picture-in-picture"></iframe></div><h2>What a data point looks like</h2>
<p>A single reading from a device looks roughly like this:</p>
<pre><code class="language-typescript">type DataPoint = {
  device: string;
  t: number;                       // epoch seconds
  d: Record&lt;string, number&gt;;       // decimals: co2, o2, pm25, temperature …
  s: Record&lt;string, string&gt;;       // strings: location, firmware, tag …
};
</code></pre>
<p><code>d</code> holds the gas concentrations and other numbers. <code>s</code> holds whatever labels the device or the user attached. Any key can be present or absent on any reading. Thousands of devices report every minute, so a report query can easily touch a million of these.</p>
<p>The backend ran on a DigitalOcean droplet with 2 GB of RAM, and it kept dying with out-of-memory errors on exactly those queries.</p>
<h2>Why a million objects is the problem</h2>
<p>Three things happen when V8 holds a million of those objects, and none of them show in the code.</p>
<p><strong>Every property access is pointer chasing.</strong> <code>points[i].d.co2</code> is not one memory read. The array holds a reference to the point, which holds a reference to <code>d</code>, which is a hash map that hashes <code>&quot;co2&quot;</code>, finds the slot and hands back the number. In C the compiler knows the offset of <code>co2</code> before the program runs. In JavaScript every value is a reference to somewhere else.</p>
<p><strong>Every object is a hash map.</strong> An object with ten keys is buckets plus a hash on every access. A million of them is ten million hashed lookups for one pass over the data.</p>
<p><strong>Every object is garbage.</strong> Each <code>{}</code> is an allocation the garbage collector tracks. Create a million per request and the collector works harder than your code. The numbers are boxed doubles spread across the heap, so the working set is far bigger than the data.</p>
<p>So the fix is not to optimize the loop. The fix is to stop creating objects.</p>
<h2>Treat the data as an image</h2>
<p>Put timestamps on the Y axis and gases on the X axis, and the readings are a grid. Some cells are empty because that device didn&#39;t report that gas at that time. That is what an image is: a rectangle of numbers in one contiguous array, addressed by <code>row * width + column</code>.</p>
<pre><code>            co2    o2    pm25   temp
t1        [ 412   20.9   35.2   26.1 ]
t2        [ 415    –     36.0   26.3 ]
t3        [  –    20.8   34.1    –   ]
</code></pre>
<p>Instead of an array of objects, the table keeps:</p>
<ul>
<li>one <code>Float64Array</code> for all decimal values, <code>times.length * gases.length</code> long</li>
<li>one <code>Int32Array</code> for string values, holding an index into a dictionary of unique strings, because labels repeat on almost every row and there&#39;s no reason to store <code>&quot;Bangalore&quot;</code> a million times</li>
<li>three small arrays for the axes: timestamps, gas names, label names</li>
</ul>
<p>A reserved value marks an empty cell. Time <code>0</code> is an unused row; an empty string is an unused column.</p>
<p>No object per reading, one allocation for the whole table. Reading <code>co2</code> at row <code>i</code> is <code>values[i * gases.length + gasIndex.co2]</code>: the offset arithmetic JavaScript normally won&#39;t give you.</p>
<h2>One class on top</h2>
<p>The buffers are ugly to use directly, so everything goes through one class. This is the surface from the first video, and it barely changed later:</p>
<pre><code class="language-typescript">class DataPointTable {
  static create(rows: number, gases: number, labels: number): DataPointTable;
  static fromJSON(points: DataPoint[]): DataPointTable;
  static fromBuffer(buf: ArrayBuffer): DataPointTable;

  // axes
  times(order?: &#39;asc&#39; | &#39;desc&#39;): Iterable&lt;number&gt;;
  gases(): Iterable&lt;string&gt;;
  labels(): Iterable&lt;string&gt;;
  timeIndex(t: number): number;         // -1 if absent
  gasIndex(name: string): number;
  addTime(t: number): number;           // returns the row index
  addGas(name: string): number;
  removeTime(t: number): void;

  // cells
  get(ti: number, gi: number): number | undefined;
  set(ti: number, gi: number, v: number): void;
  row(ti: number, onlyExisting?: boolean): Iterable&lt;[gas: string, v: number]&gt;;

  // whole table
  toJSON(): DataPoint[];
  toBuffer(): ArrayBuffer;              // for sending to the browser
  compress(): DataPointTable;           // drop unused rows and columns
  stats(): { bytes: number; rows: number; used: number };
}
</code></pre>
<p>Nobody else in the codebase has to know there are buffers underneath. A report generator asks for a row and gets <code>[gas, value]</code> pairs. It never sees a <code>Float64Array</code>.</p>
<h2>Version one: correct, and 8x slower than JSON</h2>
<p>The first version kept rows sorted by timestamp, descending, because queries always want time order. So <code>addTime</code> had to find where the new row belonged, find the nearest free slot on either side, and shift every row in between by one. Adding a gas column was worse: every row moves to make room for the new cell, so the copy runs from the back of the buffer to the front, zeroing new cells as it goes.</p>
<p>I over-allocated to soften it: five spare rows and five spare columns at a time, so a burst of inserts doesn&#39;t reallocate each time. When the table is full, allocate a bigger one, copy, carry on.</p>
<p>Every function test passed, <code>toJSON(fromJSON(x))</code> round-tripped, and <code>compress()</code> reclaimed the spare cells. Then I loaded a million points with thirty columns each. It never ran out of memory, which JSON always did at that size. It was also 8x slower than parsing the JSON.</p>
<h2>What was slow</h2>
<p>Not the typed arrays. The shifting.</p>
<p>The backend loads rows from the database, maybe runs one or two operations, then sends the data to the browser or feeds it into a report. It almost never inserts a row into the middle of a table. I was paying for ordered insertion on every load and getting nothing for it.</p>
<p>Two smaller things added to it. Finding a column by name was a linear scan of the gas array on every cell write, so a <code>name → column index</code> hash map came back, but only for the axes, which have thirty entries, not a million. And a binary search on the sorted timestamps didn&#39;t help, because the search was never the cost.</p>
<h2>Version two: stop keeping order</h2>
<p>The rewrite dropped the one assumption that hurt. Rows are no longer sorted. The table keeps two free lists instead:</p>
<pre><code class="language-typescript">private freeRows: number[];      // indexes of rows with t === 0
private freeCols: number[];      // indexes of columns with name === &#39;&#39;
private rowOf = new Map&lt;number, number&gt;();   // timestamp → row index
private colOf = new Map&lt;string, number&gt;();   // gas name → column index
</code></pre>
<p><code>addTime</code> is now: if the timestamp is known, return its row; otherwise pop a free row and record it in the map. No search, no shift. If the free list is empty, allocate a bigger table, copy once and push the new rows onto the free list. <code>removeTime</code> zeroes the row and pushes its index back. No loop in the hot path.</p>
<p>A query knows how many rows it will return before it reads them, so the table is created at the right size up front, and the grow-and-copy branch almost never runs.</p>
<p>Order comes back only where it is read. <code>times(&#39;desc&#39;)</code> collects the live timestamps, sorts them once and yields them. One sort per query instead of one shift per insert.</p>
<table>
<thead>
<tr>
<th></th>
<th>Memory with 1M points</th>
<th>Load vs <code>JSON.parse</code></th>
</tr>
</thead>
<tbody><tr>
<td>JSON objects</td>
<td>out of memory on 2 GB</td>
<td>baseline</td>
</tr>
<tr>
<td>Version one (sorted rows)</td>
<td>never ran out</td>
<td>8x slower</td>
</tr>
<tr>
<td>Version two (free lists)</td>
<td>flat</td>
<td>faster</td>
</tr>
</tbody></table>
<p>The figure in the video description, roughly a 400% gain, is the load-and-serve path measured against the JSON version on the same box.</p>
<h2>Skipping JSON entirely</h2>
<p>That is why the class has <code>toBuffer</code> and <code>fromBuffer</code>. If the point is to stop building a million objects, serializing them to JSON for the browser undoes it. So the table serializes itself as its own buffers: a small header with the dimensions and the axis arrays, then the two value buffers as they already sit in memory.</p>
<p>On the server, database rows go straight into the table without a <code>DataPoint[]</code> ever existing. The buffer goes to the browser as is (base64 where it has to cross a text boundary), and the frontend rebuilds the same class from it. <code>fromJSON</code> and <code>toJSON</code> stay for tests and the odd tool; the production path never touches them.</p>
<h2>What I&#39;d tell past me</h2>
<ul>
<li>The win came from removing allocations, not from typed arrays as such. Typed arrays are just the only way JavaScript lets you allocate a million numbers in one go.</li>
<li>Don&#39;t maintain an invariant the read path doesn&#39;t need. Sorted rows cost me a full version.</li>
<li>Keep the buffers behind one class. Every place that touched a <code>Float64Array</code> directly would have been a place the rewrite had to visit.</li>
<li>Measure against what you&#39;re replacing, on the same box. &quot;Never runs out of memory&quot; isn&#39;t a win if it&#39;s 8x slower.</li>
</ul>
<p>I had Claude write the function and performance tests for the second version. In the fourth video, the round-trip comparison catches a bug in <code>compress()</code> I wouldn&#39;t have found by hand.</p>
<p>Have you hit the point where plain objects stopped scaling in Node.js, and what did you move to?</p>
<h2>Related reading</h2>
<ul>
<li><a href="/blog/how-i-generated-heatmaps-100x-faster">IDW heatmaps, 5 seconds to 30 ms</a>: the same instinct on a 1 km grid, where the &quot;look at it as an image&quot; idea came from.</li>
<li><a href="/blog/how-caching-boosted-performance-for-a-top-air-quality-monitoring-company">Postgres triggers and our Redis cache at Oizom</a>: the other half of the same backend.</li>
<li><a href="/blog/the-hidden-cost-of-promise-in-nodejs">await on a sync function in Node.js</a>: another thing that isn&#39;t free even when it looks free.</li>
</ul>
]]></content:encoded>
</item>
<item>
  <title>await on a sync function: 2 ms to 51 ms for 1M calls in Node.js</title>
  <link>https://panth.vardayinitech.in/blog/the-hidden-cost-of-promise-in-nodejs</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/the-hidden-cost-of-promise-in-nodejs</guid>
  <pubDate>Tue, 13 May 2025 00:00:00 GMT</pubDate>
  <description>A million calls to an empty sync function take 2 ms in Node.js. Put await in front of it and the same loop takes 51 ms; in Chrome, 3 ms becomes 1,500 ms. The benchmark, the numbers for Node, Deno, Bun and Chrome, and why.</description>
  <category>node-js</category><category>promises</category><category>synchronous</category><category>asynchronous</category><category>async-await</category><category>performance</category>
  <content:encoded><![CDATA[<pre><code class="language-plaintext">1,000,000 calls to an empty sync function     2 ms
the same calls with await in front           51 ms
</code></pre>
<p>That is Node.js. In Chrome the same loop went from 3 ms to 1,500 ms. The function does nothing and is not async; the whole difference is the <code>await</code>.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. I measured this in May 2025, after Prime said in <a href="https://www.youtube.com/watch?v=i0YfiQlzv6M">a video</a> that even sync code behind a Promise waits for the event loop. I didn&#39;t believe it, so I tested it.</p>
<h2>An async function starts synchronously</h2>
<p>My first guess was that an async function with nothing to wait for would just run inline.</p>
<pre><code class="language-typescript">async function a() {
    console.log(&#39;a&#39;);
}
console.log(&#39;1&#39;);
setTimeout(() =&gt; console.log(&#39;t1&#39;)); // runs later, after the current code
a(); // sync body, so it should run right here
setTimeout(() =&gt; console.log(&#39;t2&#39;));
console.log(&#39;2&#39;);
</code></pre>
<pre><code class="language-plaintext">1
a
2
t1
t2
</code></pre>
<p>It does. <code>a</code> prints between <code>1</code> and <code>2</code>.</p>
<h2><code>.then</code> and <code>await</code> do not</h2>
<p>That could just be the <code>async</code> keyword, so the next test used a Promise constructor, <code>.then</code> and <code>await</code>.</p>
<pre><code class="language-typescript">async function a() {
    console.log(&#39;a1&#39;);
    const p = new Promise((r) =&gt; {
       console.log(&#39;p&gt;&gt;&gt;&#39;)
       r();
    }).then(() =&gt; console.log(&#39;p&lt;&lt;&lt;&#39;));
    console.log(&#39;a2&#39;);
    await p;
    console.log(&#39;a3&#39;);
}
console.log(&#39;1&#39;);
setTimeout(() =&gt; console.log(&#39;t1&#39;));
a();
setTimeout(() =&gt; console.log(&#39;t2&#39;));
console.log(&#39;2&#39;);
</code></pre>
<pre><code class="language-plaintext">1
a1
p&gt;&gt;&gt;
a2
2
p&lt;&lt;&lt;
a3
t1
t2
</code></pre>
<p>The Promise body runs at once (<code>p&gt;&gt;&gt;</code>), but the <code>.then</code> callback and everything after <code>await</code> wait until the current synchronous code has finished: <code>p&lt;&lt;&lt;</code> and <code>a3</code> print after <code>2</code>. They still run before the timers (<code>t1</code>, <code>t2</code>). The Promise resolved immediately and the work was still deferred.</p>
<h2>Measuring what the deferral costs</h2>
<p>Five ways to call an empty function a million times:</p>
<ol>
<li>a sync function, called directly</li>
<li>a sync function, called with <code>await</code></li>
<li>an async function, called without <code>await</code></li>
<li>an async function, called with <code>await</code></li>
<li>an async function, called without <code>await</code>, then <code>await Promise.all</code> on all the results</li>
</ol>
<pre><code class="language-javascript">function sf() {}
async function af() {}
function run(fn, next) {
  const start = Date.now();
  function done() {
    console.log(Date.now() - start + &quot;ms&quot;);
    if (next) next();
  }
  fn(done);
}
function case1() {
  run((done) =&gt; {
    for (let index = 0; index &lt; 1000_000; index++) {
      sf();
    }
    done();
  }, case2);
}
function case2() {
  run(async (done) =&gt; {
    for (let index = 0; index &lt; 1000_000; index++) {
      await sf();
    }
    done();
  }, case3);
}
function case3() {
  run((done) =&gt; {
    for (let index = 0; index &lt; 1000_000; index++) {
      af();
    }
    done();
  }, case4);
}
function case4() {
  run(async (done) =&gt; {
    for (let index = 0; index &lt; 1000_000; index++) {
      await af();
    }
    done();
  }, case5);
}
function case5() {
  run(async (done) =&gt; {
    const promises = [];
    for (let index = 0; index &lt; 1000_000; index++) {
      promises.push(af());
    }
    await Promise.all(promises);
    done();
  });
}
case1();
</code></pre>
<table>
<thead>
<tr>
<th></th>
<th>1. sync fn, direct</th>
<th>2. sync fn, await</th>
<th>3. async fn, no await</th>
<th>4. async fn, await</th>
<th>5. async fn, Promise.all</th>
</tr>
</thead>
<tbody><tr>
<td>Chrome</td>
<td>3ms</td>
<td>1500ms</td>
<td>33ms</td>
<td>1559ms</td>
<td>—</td>
</tr>
<tr>
<td>Chrome, fresh start</td>
<td>3ms</td>
<td>1289ms</td>
<td>33ms</td>
<td>1477ms</td>
<td>388ms</td>
</tr>
<tr>
<td>Node.js</td>
<td>2ms</td>
<td>51ms</td>
<td>7ms</td>
<td>46ms</td>
<td>171ms</td>
</tr>
<tr>
<td>Deno</td>
<td>1ms</td>
<td>49ms</td>
<td>7ms</td>
<td>42ms</td>
<td>183ms</td>
</tr>
<tr>
<td>Bun</td>
<td>2ms</td>
<td>73ms</td>
<td>19ms</td>
<td>74ms</td>
<td>130ms</td>
</tr>
</tbody></table>
<p>Every runtime pays for the <code>await</code>, and Chrome pays the most. A fresh Chrome start did not change that, so it was not my open tabs.</p>
<h2>With a body in the function</h2>
<p>Runtimes might special-case empty functions, so the second run gave both functions one line of work, <code>cnt++</code>, and reset the counter after each case:</p>
<pre><code class="language-javascript">let cnt = 0;
function sf() {
  cnt++;
}
async function af() {
  cnt++;
}
function run(fn, next) {
  const start = Date.now();
  function done() {
    cnt = 0;
    console.log(Date.now() - start + &quot;ms&quot;);
    if (next) next();
  }
  fn(done);
}
</code></pre>
<table>
<thead>
<tr>
<th></th>
<th>1. sync fn, direct</th>
<th>2. sync fn, await</th>
<th>3. async fn, no await</th>
<th>4. async fn, await</th>
<th>5. async fn, Promise.all</th>
</tr>
</thead>
<tbody><tr>
<td>Chrome, fresh start</td>
<td>3ms</td>
<td>1307ms</td>
<td>32ms</td>
<td>1493ms</td>
<td>396ms</td>
</tr>
<tr>
<td>Node.js</td>
<td>10ms</td>
<td>50ms</td>
<td>8ms</td>
<td>46ms</td>
<td>170ms</td>
</tr>
<tr>
<td>Deno</td>
<td>5ms</td>
<td>51ms</td>
<td>7ms</td>
<td>44ms</td>
<td>181ms</td>
</tr>
<tr>
<td>Bun</td>
<td>4ms</td>
<td>68ms</td>
<td>20ms</td>
<td>76ms</td>
<td>131ms</td>
</tr>
</tbody></table>
<p>Same picture. The cost is the <code>await</code>, not the empty body.</p>
<h2>What I took from it</h2>
<p>I didn&#39;t know the cost of this before I measured it. It also explained why people who write Rust and Go say async is hard, when it had always looked easy to me in JavaScript. It is easy to write, and it has a price: in these runs, putting <code>await</code> in front of code with nothing to wait for made the loop at least 5x slower in every runtime, and several hundred times slower in Chrome.</p>
<p>For high-performance code, that pushed me towards sync callbacks on hot paths. I am thinking about changing my libraries and framework to support sync callbacks fully, and maybe rewriting the backend from scratch to use them.</p>
<p>Where has an <code>await</code> on something that was already there cost you time?</p>
]]></content:encoded>
</item>
<item>
  <title>Postgres triggers invalidate our Redis cache at Oizom</title>
  <link>https://panth.vardayinitech.in/blog/how-caching-boosted-performance-for-a-top-air-quality-monitoring-company</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/how-caching-boosted-performance-for-a-top-air-quality-monitoring-company</guid>
  <pubDate>Sat, 25 Jan 2025 00:00:00 GMT</pubDate>
  <description>Cache-aside in Redis served stale reads whenever a write came from outside the app. How we moved cache invalidation into Postgres triggers at Oizom, and the listener that made it safe across instances.</description>
  <category>cache</category><category>cache-invalidation</category><category>redis</category><category>postgresql</category><category>node-js</category>
  <content:encoded><![CDATA[<p>Our Redis cache served stale data for up to 24 hours whenever a row changed somewhere the cache code couldn&#39;t see: another backend, an admin backend, a background worker, or someone editing the database by hand. The fix was to stop asking application code to remember invalidation and let Postgres announce every change itself.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. Our Node.js backends sit on Postgres and Redis. This is how we got from plain cache-aside to trigger-driven invalidation, step by step, including the bug in the first version (every backend instance heard every change) and the table-backed listener that fixed it.</p>
<p>Here is where we started, the usual cache-aside:</p>
<pre><code class="language-typescript">async function getUser(userId) {
    const cacheResult = await cache.get(`USERS:${userId}`);
    if (cacheResult !== null) return JSON.parse(cacheResult);
    const result = await (...) // do some db call
    await cache.set(`USERS:${userId}`, JSON.stringify(result), &quot;exp&quot;, 3600 * 24);
    return result;
}
</code></pre>
<p>So, how would you clear the cache? Because when a user updates their profile name, the changes don&#39;t seem to show up immediately.</p>
<pre><code class="language-typescript">async function updateUser(userId, data) {
    await (...) // do some db calls
    await cache.del(`USERS:${userId}`);
}
async function deleteUser(userId) {
    await (...) // do some db calls
    await cache.del(`USERS:${userId}`);
}
</code></pre>
<p><img src="/assets/blog/how-caching-boosted-performance-for-a-top-air-quality-monitoring-company/img1.jpeg" alt=""></p>
<p>So, you think you&#39;ve nailed caching, huh? But have you really? Let&#39;s break it down:</p>
<ol>
<li>Why is the cache invalidation code stuck in <code>updateUser</code> and <code>deleteUser</code>? It&#39;s super easy to overlook, especially when you create a new function like <code>updateUserForSomeNewTask</code>.</li>
<li>What if updating or deleting a user also affects other stuff, like the user&#39;s organization or module expiry? If you&#39;re caching all that too, you&#39;ll need to handle invalidation for everything. Who can keep track of all that?</li>
<li>Even with a simple cache get, you have to add two layers of code—one before and one after your implementation—to check if the cache exists and then set it. It&#39;s a bug waiting to happen.</li>
<li>What if you change the output schema for <code>getUser</code>? You either change the key prefix from <code>USERS:</code> to <code>USERS_V2:</code> or flush the <code>USERS:</code> cache. Who&#39;s going to remember that?</li>
<li>The big one: what if a user is updated from another service? Like someone directly edits the database, or it happens through a different backend, admin backend, or background workers? You&#39;ll be stuck with a caching bug for 24 hours, wasting time debugging.</li>
<li>And what about when you want to create a function like <code>getUsers(userIds)</code>? If you think that&#39;s easy, try making caching logic for <code>getModulesForUser(userId, moduleIds)</code> where <code>moduleIds = undefined</code> to get all modules.</li>
</ol>
<p>See how managing this can get tricky as you go along? These are real problems that happen more often than you&#39;d think.</p>
<p>All of this can be sorted out with a good design system.</p>
<p>I&#39;m going to talk about my caching and invalidation tricks using <code>Redis</code> and <code>Postgres</code> with <code>NodeJs</code>.</p>
<p>Let&#39;s tackle everything step by step.</p>
<ol>
<li>Let&#39;s start by turning the caching code into a function.</li>
</ol>
<pre><code class="language-typescript">// caching function for single object
// we can similarly create a abstract wrapper to cache multiple objects
function CacheFunc(func, getKey, expInSec) {
    async wrappedFn(...args) {
        const key = getKey(...args);
        const cacheResult = await redis.get(key);
        if (cacheResult !== null) return JSON.parse(cacheResult);
        const result = await func(...args)
        await redis.set(key, JSON.stringify(result), &quot;exp&quot;, expInSec);
        return result;
    }
    async invalidate(...args) {
        const key = getKey(...args);
        await redis.del(key);
    }
    return Object.assign(wrappedFn, { invalidate })
}

const getUser = CacheFunc(async function (userId) {
    const result = await (...) // do some db call
    return result;
}, (userId) =&gt; `USERS:${userId}`, 3600 * 24)
async function updateUser(userId, data) {
    await (...) // do some db calls
    await getUser.invalidate(userId);
}
async function deleteUser(userId) {
    await (...) // do some db calls
    await getUser.invalidate(userId);
}
</code></pre>
<p>this looks way more promising.</p>
<ol start="2">
<li>But there&#39;s still a question: What do we do when a new lightweight user function needs caching and invalidation? so let&#39;s move all the invalidation stuff to an <code>onUserChange</code> function.</li>
</ol>
<pre><code class="language-typescript">async function onUserChange(userId) {
    await Promise.all([
        getUser.invalidate(userId),
        getUserWithFewColumns.invalidate(userId),
        // add all the other, user caching functions
    ])    
}

const getUser = CacheFunc(async function (userId) {
    const result = await (...) // do some db call
    return result;
}, (userId) =&gt; `USERS:${userId}`, 3600 * 24)
const getUserWithFewColumns = CacheFunc(async function (userId) {
    const result = await (...) // do some db call
    return result;
}, (userId) =&gt; `LIGHT_USERS:${userId}`, 3600 * 24)

async function updateUser(userId, data) {
    await (...) // do some db calls
    await onUserChange(userId);
}
async function deleteUser(userId) {
    await (...) // do some db calls
    await onUserChange(userId);
}
</code></pre>
<p>It&#39;s like having a pub/sub system, where all the subscribers are inside the <code>onUserChange</code> function, and any functions that change user content, like <code>updateUser</code> and <code>deleteUser</code>, are doing the publishing.</p>
<ol start="3">
<li>There&#39;s still the problem of having to manually invalidate in <code>updateUser</code> and <code>deleteUser</code>. Someone might forget to invalidate the user cache when creating a new <code>userUpdateWithModule</code> function. So, we&#39;ll send an event from pg whenever changes happen and trigger the <code>onUserChange</code> function based on that event.</li>
</ol>
<pre><code class="language-sql">-- run this once
CREATE FUNCTION notify_change()
RETURNS TRIGGER AS $$
BEGIN
    PERFORM pg_notify(
        &#39;internal_change&#39;, 
        jsonb_build_object(
            &#39;old&#39;, row_to_json(OLD),
            &#39;new&#39;, row_to_json(NEW),
            &#39;table&#39;, TG_TABLE_NAME,
            &#39;event&#39;, TG_OP
        )::text
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- for all the tables you do caching for.
CREATE TRIGGER on_change
AFTER INSERT OR UPDATE OR DELETE ON public.&quot;USERS&quot;
FOR EACH ROW EXECUTE FUNCTION notify_change();
</code></pre>
<pre><code class="language-typescript">const pool = new pg.Pool({...});
const client = await pool.connect();
async function listenToPgChanges() {
    const callbacks = {
        USERS: onUserChange,
        // add other tables
    }
    client.on(&#39;notification&#39;, function (e) {
        if (e.channel !== &#39;internal_change&#39;) return;
        try {
            const data = JSON.parse(e.payload);
            await callbacks[data.table](data);
        } catch (err) {
            console.error(&#39;Err in internal_change notification:&#39;, err)
        }
    })
    await client.query(`LISTEN internal_change`)
}
async function onUserChange(msg) {
    if (msg.event === &#39;INSERT&#39;) {
        // new possibilities.
        await sendWelcomeEmail(msg.new)
        return;
    }
    const userId = msg.old.ID;
    await Promise.all([
        getUser.invalidate(userId),
        getUserWithFewColumns.invalidate(userId),
        // add all the other, user caching functions
    ])    
}

const getUser = CacheFunc(async function (userId) {
    const result = await (...) // do some db call
    return result;
}, (userId) =&gt; `USERS:${userId}`, 3600 * 24)
const getUserWithFewColumns = CacheFunc(async function (userId) {
    const result = await (...) // do some db call
    return result;
}, (userId) =&gt; `LIGHT_USERS:${userId}`, 3600 * 24)

function updateUser(userId, data) {
    await (...) // do some db calls
    // No need to do any thing for invalidation.
}
function deleteUser(userId) {
    await (...) // do some db calls
    // No need to do any thing for invalidation.
}
</code></pre>
<p>This is awesome. We&#39;ve tackled a bunch of issues all at once. If anyone updates the table from the database or some other backend, we can count on <code>pg</code> to send a notification about the changes, and our server will handle them accordingly. Plus, we&#39;ve totally removed the need for invalidation in <code>updateUser</code> and <code>deleteUser</code>.</p>
<p><img src="/assets/blog/how-caching-boosted-performance-for-a-top-air-quality-monitoring-company/img2.jpeg" alt=""></p>
<p>With just 3 simple steps, we&#39;ve solved 90% of our problems. But there are still a few things we haven&#39;t figured out yet:</p>
<ol>
<li>The code design isn&#39;t perfect yet. The invalidation call <code>getUser.invalidate</code> happens in the <code>onUserChange</code> function, but we want a proper callback using <code>Pub/Sub</code>.This all can be further solved using my library, i have abstract the code for anyone to use. so that you can spent time writing important business logic and not wonder the caching bugs.</li>
<li>We only have one caching wrapper to cache the function output. We need a solution for functions like <code>getUsers</code> that handle multiple users.</li>
<li>Changes in the output schema for caching functions. We need to check the schema before deciding if the cached output is valid, without changing the cache key prefix or manually flushing keys from Redis.</li>
</ol>
<p><strong>NOTE BUG:</strong> In the current setup, when we use <code>pg_notify</code>, it sends notifications to all the backend instances you have running. This means you&#39;ll end up invalidating the same cache multiple times because each backend instance listens to the event separately. It might be okay to invalidate the cache several times, but if you&#39;re using this for something like <code>sendWelcomeEmail</code>, it could be a problem. Don&#39;t worry, my solution will fix all these issues for you.</p>
<p>Here&#39;s the solution I came up with in the end:</p>
<ol>
<li><a href="https://jsr.io/@panth977/tools">https://jsr.io/@panth977/tools</a> - This lets us set up Pub/Sub for calling internal functions.</li>
<li><a href="https://jsr.io/@panth977/functions">https://jsr.io/@panth977/functions</a> - This helps us define functions with zod input/output schemas and wrappers. It makes our code more consistent and easier to read.</li>
<li><a href="https://jsr.io/@panth977/cache">https://jsr.io/@panth977/cache</a> - An abstract wrapper with a bunch of hooks for single object caching, multiple object caching, collection caching, and more. (You&#39;ll need a cache that supports <code>key → value</code> and <code>key → hash (field → value)</code>)</li>
<li><a href="https://jsr.io/@panth977/cache-redis">https://jsr.io/@panth977/cache-redis</a> - A Redis adapter for the <a href="https://jsr.io/@panth977/cache">/cache</a> package.</li>
</ol>
<p>Let&#39;s tackle the rest step by step.</p>
<ol>
<li>Let&#39;s use Pub/Sub from <a href="https://jsr.io/@panth977/tools">/tools</a> (we&#39;ll be using <a href="https://zod.dev/">zod</a> as the schema validation library).</li>
</ol>
<pre><code class="language-typescript">const zUSER = z.object({
    ID: z.number(),
    NAME: z.string(),
    // ...
})
const zEvent z.enum([&#39;INSERT&#39;, &#39;UPDATE&#39;, &#39;DELETE&#39;])
function zChanges(schema) {
    return z.object({ event: zEvent, old: schema.nullable(), new: schema.nullable() });
}
const TablesChanges = {
    // from @panth977/tools
    USERS: TOOLS.CreatePubsub(zChanges(zUSER))
}

async function onPgChanges() {
    client.on(&#39;notification&#39;, function (msg) {
        if (msg.channel !== &#39;internal_change&#39;) return;
        try {
            const data = JSON.parse(msg.payload);
            await TablesChanges[data.table].publish(data);
        } catch (err) {
            console.error(&#39;Err in internal_change notification:&#39;, err)
        }
    })
    await client.query(`LISTEN internal_change`)
}

TablesChanges.USERS.subscribe((msg) =&gt; msg.old &amp;&amp; getUser.invalidate(msg.old.ID));
const getUser = CacheFunc(function (userId) {
    const result = await (...) // do some db call
    return result;
}, (userId) =&gt; `USERS:${userId}`, 3600 * 24);
TablesChanges.USERS.subscribe((msg) =&gt; msg.old &amp;&amp; getUserWithFewColumns.invalidate(msg.old.ID));
const getUserWithFewColumns = CacheFunc(async function (userId) {
    const result = await (...) // do some db call
    return result;
}, (userId) =&gt; `LIGHT_USERS:${userId}`, 3600 * 24);
</code></pre>
<p>Perfect, this lets us directly subscribe to our changes without needing a middle function like <code>onUserChange</code>.</p>
<ol start="2">
<li>Let&#39;s use <a href="https://jsr.io/@panth977/cache">/cache</a>. It already has the code abstracted with <code>CacheFunc</code> for Single Object and Multiple Object outputs, as well as Single Collection (and even Multiple Collection for those tricky situations).</li>
</ol>
<pre><code class="language-typescript">// from @panth977/cache
const cache = new CACHE.CacheController({
    // from @panth977/cache-redis
    client: new RedisCacheClient(redis.createClient({...}), { delayInMs: 10, label: &#39;Redis&#39; }),
    separator: &#39;:&#39;,
    defaultExpiry: 3600 * 24,
    prefix: &#39;Dev&#39;,
    allowed: { &#39;*&#39;: true },
    log: true,
});
TablesChanges.USERS.subscribe((msg) =&gt; msg.old &amp;&amp; getUser.getHooks(msg.old.ID).del());
const getUser = CACHE.Wrap((userId) =&gt; new CACHE.HOOKS.SingleObject({
    cache: cache
        .addPrefix(&#39;USERS&#39;)
        .addPrefix(JSON.stringify({ userId }))
        .setDefaultExp(3600 * 24),
}), function ($, userId) {
    const result = await (...) // do some db call
    return result;
})
getUser(1001)
getUser(1002)
</code></pre>
<p>this might seem a bit much, but keep in mind <code>CACHE.HOOKS</code> has other hooks like:</p>
<pre><code class="language-typescript">const getUsers = CACHE.Wrap((userIds) =&gt; new CACHE.HOOKS.MultipleObject({
    cache: cache
        .addPrefix(&#39;USERS&#39;)
        .setDefaultExp(3600 * 24),
    ids: userIds,
}), function ($, userIds) {
    userIds = $.notFound; // only run for ids not found in cache
    // make sure you fetch all user data in single db request.
    const result = await (...) // do some db call
    // NOTE: use TOOLS.oneToOneMapping from @panth977/tools
    const dictResult = (...) // Make sure result is { [userId: number]: UserObject }
    return dictResult;
})
// lets say user:1001 &amp; user:1003 exists;
getUsers([1001, 1002]) // { 1001: USER_DATA }
// --&gt; will set cache for 1001
getUsers([1001, 1003]) // { 1001: USER_DATA, 1003: USER_DATA }
// --&gt; will get 1001 from cache &amp; set cache for 1003

// ------------ // ------------ //
const getUserDevices = CACHE.Wrap((userId, deviceIds) =&gt; new CACHE.HOOKS.SingleCollection({
    cache: cache
        .addPrefix(&#39;USER_DEVICES&#39;)
        .addPrefix(JSON.stringify({ userId }))
        .setDefaultExp(3600 * 24),
    subIds: deviceIds ? deviceIds : &#39;*&#39;,
}), function ($, userId, deviceIds) {
    deviceIds = $.notFound === &#39;*&#39; ? undefined : $.notFound;
    const ignoreDeviceIds = $.found;
    // make sure you fetch all user devices data in single db request. 
    // if deviceIds = undefined, then fetch all the device
    // and ignore ones already found from cache.
    const result = await (...) // do some db call
    // NOTE: use TOOLS.oneToOneMapping from @panth977/tools
    const dictResult = (...) // Make sure result is { [deviceId: string]: DeviceObject }
    return dictResult;
})
// lets say user:1001 has device:OZ001, device:OZ002 &amp; device:OZ003
getUserDevices(1001, [&#39;OZ001&#39;, &#39;OZ005&#39;]) // { &#39;OZ001&#39;: DEVICE_DATA }
// will set cache for (1001 -&gt; OZ001)
getUserDevices(1001) // { &#39;OZ001&#39;: DEVICE_DATA, &#39;OZ002&#39;: DEVICE_DATA, &#39;OZ003&#39;: DEVICE_DATA }
// will set cache for (1001 -&gt; OZ002) &amp; set cache for (1001 -&gt; OZ003) 
getUserDevices(1001, [&#39;OZ003&#39;]) // { &#39;OZ003&#39;: DEVICE_DATA }
// will get (1001 -&gt; OZ003) from cache
getUserDevices(1001) // { &#39;OZ001&#39;: DEVICE_DATA, &#39;OZ002&#39;: DEVICE_DATA, &#39;OZ003&#39;: DEVICE_DATA }
// will get fully from cache.
getUserDevices(1001, [&#39;OZ004&#39;]) // {}
// will figure form cache.
</code></pre>
<p>These APIs will help us cache almost all our cases.</p>
<ol start="3">
<li>Let&#39;s use <a href="https://jsr.io/@panth977/functions">/functions</a> to make our code design more consistent. This will also make sure your cache has the right schema.</li>
</ol>
<pre><code class="language-typescript">const getUsers = FUNCTIONS.AsyncFunction.build({
    input: z.number().array(),
    output: z.record(z.number(), zUSER),
    wrappers: (_params) =&gt; [
        CACHE.Wrapper({
            _params,
            getHook: ({ input }) =&gt; new CACHE.HOOKS.MultipleObject({
                cache: cache.addPrefix(&#39;USERS&#39;),
                ids: input,
            }),
            updateInput: ({ info }) =&gt; info.notFound,
            useHook(hook) {
                // NOTE: [useHook] function gets executed once 
                // for this case it is when [getUsers] is being build.
                TablesChanges.USERS.subscribe(
                    (msg) =&gt; msg.old 
                        &amp;&amp; hook({ input: [msg.old.ID] }).del()
                );
            },
        }),
    ],
    func({ input }) {
        const result = await (...) // do some db call
        const dictResult = (...) // Make sure result is { [userId: number]: UserObject }
        return dictResult;
    }
})
</code></pre>
<p>This takes care of all our code issues. We&#39;ll deal with the <code>pg_notify</code> problem next.</p>
<p>Now, let&#39;s tackle the last problem: listening to database changes.</p>
<pre><code class="language-sql">-- drop the earlier function &amp; trigger
DROP TRIGGER IF EXISTS on_change ON public.&quot;USERS&quot;;
DROP FUNCTION IF EXISTS notify_change();

-- we will store all the changes to a table
CREATE TABLE public.&quot;INTERNAL_CHANGES_V2&quot; (
    &quot;ID&quot; SERIAL PRIMARY KEY,
    &quot;TXID&quot; TEXT NOT NULL DEFAULT txid_current(),
    &quot;TABLE&quot; VARCHAR(255) NOT NULL,
    &quot;EVENT&quot; VARCHAR(255) NOT NULL,
    &quot;OLD&quot; JSONB NOT NULL,
    &quot;NEW&quot; JSONB NOT NULL,
    &quot;CREATED_AT&quot; TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_internal_changes_txid_table_created_at
ON public.&quot;INTERNAL_CHANGES_V2&quot; (&quot;TXID&quot;, &quot;TABLE&quot;, &quot;EVENT&quot;, &quot;IDENTITY&quot;, &quot;CREATED_AT&quot;);

-- we will maintain a table about which all changes have already been observed by a subscriber.
CREATE TABLE public.&quot;INTERNAL_CHANGES_LISTENED_V2&quot; (
    &quot;INTERNAL_CHANGES_ID&quot; INTEGER NOT NULL REFERENCES public.&quot;INTERNAL_CHANGES_V2&quot;(&quot;ID&quot;) ON DELETE CASCADE,
    &quot;LISTENER_ID&quot; VARCHAR(255),
    PRIMARY KEY (&quot;INTERNAL_CHANGES_ID&quot;, &quot;LISTENER_ID&quot;) 
);
CREATE INDEX idx_internal_changes_listened
ON public.&quot;INTERNAL_CHANGES_LISTENED_V2&quot; (&quot;INTERNAL_CHANGES_ID&quot;, &quot;LISTENER_ID&quot;);

-- we will delete all the changes that past 20 mins
CREATE OR REPLACE FUNCTION delete_rows_in_internal_changes()
RETURNS trigger
AS $$
BEGIN
    DELETE FROM public.&quot;INTERNAL_CHANGES_V2&quot;
    WHERE &quot;CREATED_AT&quot; &lt; NOW() - INTERVAL &#39;20 min&#39;;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_delete_rows_internal_changes
AFTER INSERT ON public.&quot;INTERNAL_CHANGES_V2&quot;
FOR EACH ROW EXECUTE FUNCTION delete_rows_in_internal_changes();

-- we will delete cleanups, can be avoided by CASCADE DELETE
CREATE FUNCTION delete_rows_in_internal_listened()
RETURNS trigger
AS $$
BEGIN
    DELETE FROM public.&quot;INTERNAL_CHANGES_LISTENED_V2&quot;
    WHERE &quot;INTERNAL_CHANGES_ID&quot; = OLD.&quot;ID&quot;;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_delete_rows_internal_listened
AFTER DELETE ON public.&quot;INTERNAL_CHANGES_V2&quot;
FOR EACH ROW EXECUTE FUNCTION delete_rows_in_internal_listened();

-- on any changes added to the table notify all the clients
CREATE FUNCTION on_internal_change_added()
RETURNS TRIGGER AS $$
BEGIN
  PERFORM pg_notify(&#39;internal_change&#39;, &#39;1&#39;);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER on_change_added
AFTER INSERT ON public.&quot;INTERNAL_CHANGES_V2&quot;
FOR EACH ROW EXECUTE FUNCTION on_internal_change_added();

-- create on change generic function.
CREATE FUNCTION insert_changes()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO public.&quot;INTERNAL_CHANGES_V2&quot;(&quot;TABLE&quot;, &quot;EVENT&quot;, &quot;OLD&quot;, &quot;NEW&quot;)
    SELECT TG_TABLE_NAME, TG_OP, row_to_json(OLD), row_to_json(NEW);
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

-- add the trigger to all your desired table.
CREATE TRIGGER on_change
AFTER INSERT OR UPDATE OR DELETE ON public.&quot;USERS&quot;
FOR EACH ROW EXECUTE FUNCTION insert_changes();
</code></pre>
<pre><code class="language-typescript">function tableChangeSchema&lt;S extends z.ZodRawShape&gt;(
  schema: z.ZodObject&lt;S&gt;
) {
  return z.object({
    context: z.instanceof(FUNCTIONS.DefaultContext),
    event: z.enum([&#39;INSERT&#39;, &#39;UPDATE&#39;, &#39;DELETE&#39;]),
    old: schema.nullable(),
    new: schema.nullable(),
  });
}
export const TablesChanges = {
  USERS: tableChangeSchema(zUSER)
  // add other tables...
}
const fetchLimit = 50;
const restartSec = 300;
const listenTimeout = 1000 * 30;
// this query will fetch all the new records form &quot;INTERNAL_CHANGES_V2&quot; 
// and add its observation entry in &quot;INTERNAL_CHANGES_LISTENED_V2&quot;
// it will also ignore all the rows added in transaction, but not yet commited. 
const query = `
WITH
SELECTED_CHANGES AS (
  SELECT &quot;ID&quot;, &quot;TABLE&quot;, &quot;EVENT&quot;, &quot;OLD&quot;, &quot;NEW&quot;
  FROM public.&quot;INTERNAL_CHANGES_V2&quot; C
  WHERE 
    &quot;TXID&quot; NOT IN (
      SELECT backend_xmin::text
      FROM pg_stat_activity
      WHERE state IN (&#39;active&#39;, &#39;idle in transaction&#39;) AND backend_xmin::text IS NOT NULL
    ) 
    AND NOT EXISTS (
      SELECT 1 
      FROM public.&quot;INTERNAL_CHANGES_LISTENED_V2&quot; 
      WHERE &quot;INTERNAL_CHANGES_ID&quot; = &quot;ID&quot; AND &quot;LISTENER_ID&quot; = {{{SqlStr listenId}}}
    )
    AND &quot;TABLE&quot; IN (${Object.keys(InternalChanges).map(x =&gt; `&#39;{x}&#39;`).join(&#39;, &#39;)})
  ORDER BY &quot;ID&quot; ASC
  LIMIT ${fetchLimit}
),
ADD_LISTENER AS (
  INSERT INTO public.&quot;INTERNAL_CHANGES_LISTENED_V2&quot; (&quot;INTERNAL_CHANGES_ID&quot;, &quot;LISTENER_ID&quot;)
  SELECT &quot;ID&quot;, ?
  FROM SELECTED_CHANGES
)
SELECT &quot;TABLE&quot;, &quot;EVENT&quot;, &quot;OLD&quot;, &quot;NEW&quot; FROM SELECTED_CHANGES;
`;
// this function will constantly observe to pg changes.
export async function pgListenInternalChanges(): Promise&lt;void&gt; {
  console.log(&#39;LISTEN PG INTERNAL CHANGES&#39;);
  let lastResult: undefined | null | number | void = 1;
  while (typeof lastResult === &#39;number&#39;) {
    // if last observation was 0 rows
    // we will wait for any changes to happen
    if (!lastResult) await listenNewEntry();
    // we will create a [context]
    await FUNCTIONS.DefaultContext.Builder.forTask(&#39;Pg-Internal-Changes&#39;, async function (context, done) {
      // check &amp; publish the changes.
      lastResult = await publish(context).catch(console.error);
      done();
    });
  }
  console.log(&#39;exited publishInternalChanges dew to some error, will start after a while&#39;);
  await new Promise((resolve) =&gt; setTimeout(resolve, 1000 * restartSec));
  return pgListenInternalChanges();
}
const pool = new pg.Pool({...});
const client = await pool.connect();
async function listenNewEntry() {
  // a promise that resolves on 1 msg received successfully!
  const newEntry = new Promise&lt;any&gt;(async (resolve, reject) =&gt; {
    try {
      client.on(&#39;notification&#39;, resolve);
      client.on(&#39;error&#39;, reject);
      // start listning!
      await client.query(&#39;LISTEN internal_change&#39;);
    } catch (err) {
      reject(err);
    }
  });
  newEntry.then(() =&gt; console.log(&#39;Listen a new Change!&#39;));
  // fallback of some timeout, if no msg was reveived.
  const timeout = TOOLS.delay(listenTimeout);
  // check which one resolves first.
  await Promise.race([newEntry, timeout]);
  // stop listning!
  client.query(&#39;UNLISTEN internal_change&#39;).catch(console.error);
}
async function publish(context: FUNCTIONS.DefaultContext) {
  // run the query.
  const result = await pool.query(query, CONFIG.env.LISTEN_PG_INTERNAL_CHANGES);
  const jobs: Promise&lt;void&gt;[] = [];
  for (const row of result) {
    // publish the change.
    const promise = TablesChanges[row.TABLE as keyof typeof TablesChanges].publish({
      event: row.EVENT,
      old: row.OLD, 
      new: row.NEW,
      context: context,
    });
    jobs.push(promise);
  }
  // wait for all jobs to complete.
  await Promise.all(jobs);
  // return changes observed.
  return result.length;
}
</code></pre>
<p>That&#39;s it!</p>
<p>Now you know how we clear the cache.</p>
<p>How does your team invalidate a cache when the write comes from a service that doesn&#39;t know the cache exists?</p>
]]></content:encoded>
</item>
<item>
  <title>5 seconds to 30 ms: rebuilding IDW heatmaps in Go</title>
  <link>https://panth.vardayinitech.in/blog/how-i-generated-heatmaps-100x-faster</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/how-i-generated-heatmaps-100x-faster</guid>
  <pubDate>Wed, 15 Jan 2025 00:00:00 GMT</pubDate>
  <description>Our air-quality heatmaps took about 5 seconds per image on an hourly Python cron. An on-request Go service with precomputed weights and JPEG instead of Base64 PNG does 20 images in 30 ms, for any past time range.</description>
  <category>go</category><category>performance</category><category>data-visualization</category><category>geospatial</category>
  <content:encoded><![CDATA[<p>Our air-quality heatmaps took about 5 seconds per image, on an hourly Python cron that used 4 GB of RAM and 2 CPUs and still wasn&#39;t enough. The rebuild is a Go service that makes them on request: 20 images in 30 ms, for any past time range.</p>
<p>I lead the software team at Oizom, an air-quality monitoring company (the platform is Envizom). This is how the heatmap backend was rebuilt there, including the bottleneck I didn&#39;t expect: PNG encoding.</p>
<h2>How the old version worked</h2>
<p>We used the IDW (inverse distance weighting) algorithm with wind speed and direction to figure out the value for each spot.</p>
<ol>
<li>We set up a heatmap config in our database.</li>
<li>A cron job ran every hour.</li>
<li>We grabbed all the configs.</li>
<li>For each config, we had boundaries, known devices, limits, and color codes.</li>
<li>We pre-computed a grid of small 1km x 1km boxes and stored it in the CDN.</li>
<li>We used the center of each box in the pre-computed grid as the unknown value.</li>
<li>We knew each gas parameter for all devices, plus real-time geolocation, wind speeds, and directions.</li>
<li>Using IDW with wind effects, we calculated the value for each box in the grid.</li>
<li>Colors were added to the grid based on user-defined limits.</li>
<li>A Base64 PNG image was created and stored in a time-series database.</li>
<li>This image was served to the frontend when requested.</li>
</ol>
<p><img src="/assets/blog/how-i-generated-heatmaps-100x-faster/img1.jpeg" alt=""></p>
<p>This was written in Python and used up a ton of resources (4GB RAM, 2 CPUs, 1-2 instances depending on the load), and it still wasn&#39;t enough. Each image took 10-20 seconds to process. It just wasn&#39;t fast. Back then, heatmap was a brand new feature, and we only had 3 configs, so it clearly wasn&#39;t scalable. Plus, on closer look, the wind effects weren&#39;t even used correctly.</p>
<h2>A weekend proof of concept in the browser</h2>
<p>When I found out about this, I suggested generating the heatmap on the frontend. My boss thought the idea was nuts. He didn&#39;t believe it was even possible. My argument was simple: today, there are tools like Canva and Figma, and many others that let you edit photos and videos right in the browser. There are even proper 2D games and simulations being played in the browser, and some resume websites are like 3D games. Technology has improved, and coding patterns and styles have changed a lot in the past 2 years, so I thought it was doable. He was convinced and decided to hire an intern to do the R&amp;D. Typical corporate.</p>
<p>This really bugged me because I&#39;ve always wanted to dive into image generation projects, and this seemed like my golden opportunity. So, I spent a weekend putting together a POC for a heatmap in the browser. It worked.</p>
<p><a href="https://youtu.be/2wsYBaBtCY4">https://youtu.be/2wsYBaBtCY4</a></p>
<p>This was just the start. I still needed to add wind effects, map (long, lat) to (x, y) coordinates, include user-defined boundaries (from a GeoJson polygon), and finally, add gradient colors.</p>
<p>Boss saw it, boss liked it, and everyone was happy.</p>
<p>He still had a good point. We don&#39;t want to do this on the frontend because our frontend runs on old phones, corporate laptops, and sometimes even TVs—in other words, places without much computing power. So, maybe we can set up a proxy server to handle all the heatmaps and generate them on the fly.</p>
<h2>Node.js, then Go, then the real bottleneck</h2>
<p>So, I went ahead and set up a Node server and built the whole thing. It worked great. Turns out, when you use gradient colors, there&#39;s not much difference between a 150px resolution and a 1000px resolution, except for having sharper edges.</p>
<p><img src="/assets/blog/how-i-generated-heatmaps-100x-faster/img2.png" alt=""></p>
<p>Test case: each request with 100px resolution, 10 known points with 40 images. Each load ran for 5 seconds.</p>
<p>In Node.js the results were already so much better than anything we had before. After this, I decided that to make it even faster, we should use a lower-level language. We also wanted to ensure the language is easy for other developers to understand. So, I chose GoLang. Go was faster and throttled less.</p>
<p>Something still felt off; it shouldn&#39;t take this long. After adding time logs for each function, I discovered the bottleneck was Base64 PNG encoding. Each image took 1-3ms, which was 80% of the time. Switching to JPEG boosted performance dramatically.</p>
<table>
<thead>
<tr>
<th>Load (5 s)</th>
<th>Node.js</th>
<th>Go, Base64 PNG</th>
<th>Go, JPEG</th>
</tr>
</thead>
<tbody><tr>
<td>5 req/sec</td>
<td>108ms-874ms (average 419ms)</td>
<td>80ms-175ms (average 114ms)</td>
<td>18ms-56ms (average 29ms)</td>
</tr>
<tr>
<td>50 req/sec</td>
<td>97ms-26,588ms (average 14,687ms, with 2 req/sec failing)</td>
<td>157ms-265ms (average 458ms)</td>
<td>29ms-132ms (average 88ms)</td>
</tr>
<tr>
<td>500 req/sec</td>
<td>172ms-37048ms (average 22,152ms, with 432 requests failing)</td>
<td>241ms-10,891ms (average 4,405ms, 296 req/sec failing)</td>
<td>44ms-929ms (average 496ms, 80 req/sec failing)</td>
</tr>
</tbody></table>
<p>The only downside to JPEG is that it cannot create transparent images, as it lacks an alpha channel. However, we have a straightforward solution: let the backend handle all the computational tasks, while the frontend focuses on one task—masking the image and selecting only the required portions. This can be accomplished in the frontend using JavaScript, within 10-30ms. If the client can render GeoJSON, maps, and images, it can also perform image masking.</p>
<p>This approach also offers an advantage: the difference between a 150px and a 1000px image is that the 1000px image has sharper edges. To achieve sharper edges for a 150px image, we generate an image with an additional 2px around the boundary and crop the necessary section in the frontend. This results in sharper edges.</p>
<h2>What the new pipeline does</h2>
<ol>
<li>The frontend creates a config from backend APIs and stores it in the database.</li>
<li>The frontend requests images for a config, gas parameters, and given time bounds.</li>
<li>The backend fetches all the resources and sets up a payload to send to the GoLang heatmap server.</li>
<li>In the GoLang server: It takes all the boundary points in (long, lat), maps them to (x, y) within 0-1, creates a grid of boxes based on resolution, and finds the ones within the polygon.</li>
<li>It converts all the known points/devices&#39; locations from (long, lat) to (x, y) based on grid transformation.</li>
<li>This grid context is used to precompute the weight of each known point on each target pixel.</li>
<li>Based on the given colors, we create a gradient of values ranging from 0-256 integers.</li>
<li>We set up the encoder and precompute the (x, y) to (pixel position) in the data image array.</li>
<li>For each snapshot of values, we quickly compute the grid&#39;s value using precomputed weights.</li>
<li>We magnify each value to bring it between 0-256.</li>
<li>We fill the RGB values in the data image array.</li>
<li>We use the encoder for the base64 image.</li>
<li>These images are sent back to the core server.</li>
<li>The core server sends them back to the client.</li>
<li>The frontend masks the required portion using geo boundaries. Done!</li>
</ol>
<p><img src="/assets/blog/how-i-generated-heatmaps-100x-faster/img3.jpeg" alt=""></p>
<h2>Before and after</h2>
<p>Images are generated on the fly, so we can now create past images too. We use fewer resources, which makes everything cheaper overall. Plus, it&#39;s finally scalable and versatile.</p>
<ol>
<li>Heatmaps were only available after they were created → Now we can get heatmaps for any time range.</li>
<li>For a fixed 1-hour average → You can completely customize it.</li>
<li>5 seconds per image → Now it&#39;s 30 ms for 20 images.</li>
<li>Python → GoLang.</li>
<li>Complex scheduler-based solution → Simple request/response-based solution.</li>
<li>Fixed options → Custom options for resolution, distance power, wind power, and wind effect.</li>
</ol>
<p>Finally:</p>
<p><img src="/assets/blog/how-i-generated-heatmaps-100x-faster/img4.png" alt=""></p>
<p>Of course, the data used to create this heatmap is fake and random. But this is how it would look.</p>
<p>The fix I didn&#39;t see coming wasn&#39;t the language or the algorithm. It was a time log on every function, which showed the image encoder taking 80% of the time. What was the last bottleneck you found that way, somewhere you weren&#39;t looking?</p>
]]></content:encoded>
</item>
<item>
  <title>Request-scoped logging in Node.js and Express at Oizom</title>
  <link>https://panth.vardayinitech.in/blog/implementing-request-based-logging-for-enhanced-debugging-in-leading-air-quality-companies</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/implementing-request-based-logging-for-enhanced-debugging-in-leading-air-quality-companies</guid>
  <pubDate>Sat, 04 Jan 2025 00:00:00 GMT</pubDate>
  <description>Every request in our Express APIs at Oizom gets a context id, every function logs through context.log, and the logs of a request are kept only when it fails or the frontend asks. One id then gives you every API call by a user, and from there the full log of the request that broke.</description>
  <category>logging</category><category>node-js</category><category>express</category><category>cassandra</category><category>debugging</category>
  <content:encoded><![CDATA[<p>When an API call fails with a 500 in production, I want one id from the response and, behind it, every log line that request wrote, in order. At Oizom every request gets a context id, every function logs through <code>context.log</code>, and the logs of a request are only kept when it fails or the frontend asks for them.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom, where our devices send tens of thousands of air-quality readings to the cloud every minute. This post is how that logging works in our Node.js and Express backends, with the code. Hosted logging services can collect all of this, but none of them group logs by request out of the box, and running a separate service per request is not an option.</p>
<h1>The problem</h1>
<p>A client makes a request and the API answers with a 500. To debug it you would normally try to recreate the bug, probably by logging in to the frontend as that user. The faster route is to keep the logs of every request that answered 500, keyed by its <code>requestId</code>, and read them.</p>
<h1>The idea</h1>
<ol>
<li>Every time a request comes in, generate a UUID and include it in the response headers. Whenever anything fails, the API sends a <code>requestId</code> that the client can show, in a sandbox view or similar.</li>
<li>Create a <code>context</code> object (with <code>requestId</code> as <code>context.id</code>) that includes a method <code>context.log</code>, which works like <code>console.log</code>.</li>
<li>When creating the <code>context</code>, ensure there is an implementation of <code>context.log</code> where you can access <code>context.id</code>. This allows you to group the logs by <code>requestId</code> effectively.</li>
</ol>
<h1>Solution</h1>
<p>My libraries <a href="https://jsr.io/@panth977/functions">@panth977/functions</a> and <a href="https://jsr.io/@panth977/routes">@panth977/routes</a> do this out of the box.</p>
<h2>Create Context</h2>
<pre><code class="language-typescript">// Create Context like following
app.get(&#39;/v1/user/:userId&#39;, function (req, res) {
    const id = null; // either set id, to string or leave it null for auto generate UUID
    DefaultContext.Builder.forTask(id, async function (context, done) {
        res.on(&quot;finish&quot;, done);
        context.log(&#39;My task started!&#39;);
        const user = await getUser({context, input: req.params}); // pass context to all the functions, for access to context.log
        res.send(user);
        context.log(&#39;My task completed!&#39;);
    });
});
// OR
// use @panth977/routes api to skip the process of creating functions!
export const GET_USER = ROUTES.Http.build([WebsiteAuthValidator], &#39;get&#39;, &#39;/v2/user/{userId}&#39;, {
    request: ROUTES.z.HttpRequest({path: getUser.input}),
    response: ROUTES.z.HttpResponse({body: getUser.output}),
    async func({context, path}) {
        context.log(&#39;My route started!&#39;);
        const user = await getUser({context, input: path}); // pass context to all the functions, for access to context.log
        context.log(&#39;My route completed!&#39;);
        return {body: user};
    }
})
</code></pre>
<h2>Make every function take <code>context</code></h2>
<p>That way every function can use <code>context.log</code>.</p>
<pre><code class="language-typescript">const getUser = FUNCTIONS.AsyncFunction.build({
    input: z.object({userId: z.coerce.number()}),
    output: z.object({name: z.string(), ...}).nullable(),
    async func({context, input}) {
        context.log(&#39;Getting user&#39;, input);
        const [user] = pg.query(`SELECT * FROM users WHERE id = ? LIMIT 1`, [input.userId]);
        context.log(&#39;Found user&#39;, user);
        return user ?? null;
    }
});
</code></pre>
<h2>Capture <code>context.log</code></h2>
<p>Every call to <code>context.log</code> runs your subscribed callback.</p>
<pre><code class="language-typescript">const unsub = DefaultContext.onLog(function (context, args) {
    console.log(context.id, ...args);
    // send the log to socket 
    // store to database
    // write to a file!
});
// incase you like to unsubscribe!
unsub();
</code></pre>
<p>By default, <code>context.log</code> doesn&#39;t write to the console at all, so add <code>console.log</code> in the callback if you want it.</p>
<h1>How we use it at Oizom</h1>
<p>At Oizom our devices send calibrated air-quality data to the cloud, tens of thousands of entries every minute, and clients read their live data through our APIs. With that many requests, debugging anything in production by reading a shared log stream was close to impossible.</p>
<h4>What I wanted</h4>
<ol>
<li>Keep logs only for failed requests, so successful ones don&#39;t clutter the logs database.</li>
<li>A way to list the request IDs made by <code>user</code> from the website, <code>user</code> from third-party APIs, <code>device</code> from the device itself, and <code>system-{type}</code> for any internal system calls like cron jobs, reports, alerts, etc.</li>
<li>The full logs for any one request ID.</li>
</ol>
<h4>What I built</h4>
<ol>
<li><p>Define a log payload</p>
<pre><code class="language-typescript"> type log = {
     ids: string[];
     msg: string;
     ts: number;
     line: number;
 }
</code></pre>
<p>Many logs belong to the same ID, so why can one log belong to many IDs? Most logs do have a single ID, <code>context.id</code>. Multiple IDs are there for filtering: the same log line can be found by request, by user, by organisation or by login session. More on that below.</p>
</li>
<li><p>A secure socket app with three channels:</p>
<ol>
<li><code>/send_log</code> - every server sends its logs through this channel.</li>
<li><code>/stream_all_logs</code> - streams all logs in real time as the servers send them.</li>
<li><code>/stream_logs</code> - streams only the logs that match its room (one room per <code>id</code>).</li>
</ol>
</li>
<li><p>A service that listens to <code>/stream_all_logs</code> and stores every log. For us that is Cassandra, self-hosted in a container, with a <code>ttl</code> of a few weeks.</p>
<pre><code class="language-sql"> CREATE TABLE logs.logs_v2 (
   id text,
   ids set&lt;text&gt;,
   ts bigint,
   msg text,
   line bigint,
   PRIMARY KEY ((id), ts)
 ) WITH CLUSTERING ORDER BY (ts ASC) AND ...
</code></pre>
</li>
<li><p>A service with an HTTP GET endpoint <code>/fetch_logs</code> that reads logs by <code>id</code> from Cassandra, ordered, with limit/offset (Cassandra has token-based pagination built in).</p>
</li>
<li><p>A small frontend with two modes: [ <code>real-time</code> , <code>database</code> ]</p>
<ol>
<li>A text box for the <code>id</code> and a submit button.</li>
<li>Depending on the mode, it connects to the socket <code>/stream_logs</code> or fetches from <code>/fetch_logs</code>, with a &quot;fetch next&quot; button for more.</li>
<li>Each log has an expand button that shows all its <code>ids</code>, which link to the other ids that share the log.</li>
</ol>
</li>
<li><p>That is the logging system. The core is in the backend. Every time a <code>context</code> is created:</p>
<ol>
<li>It gets a logs state.</li>
<li>Every <code>context.log</code> entry goes into that state.</li>
<li>On an error, or when something needs to be kept, we set <code>.importance = true</code>.</li>
<li>On dispose, if <code>.importance === true</code>, all the logs are streamed to <code>/send_log</code>.</li>
</ol>
</li>
</ol>
<pre><code class="language-typescript">    type Logs = {
        logs: { ids: string[]; msg: string; ts: number; line: number }[];
        important: boolean;
    };
    const LogsStateKey = FUNCTIONS.DefaultContextState.CreateKey&lt;Logs&gt;({
        label: &#39;Logs&#39;,
        scope: &#39;global&#39;,
    });
    const stringifyLogs = LOGS.createStringifyLogger({ forEachLine: false });
    // exposing this function for set the importance level.
    // this determines wether logs should be sent to socket or not!
    export function needToSendLogsToSocket(context: Pick&lt;FUNCTIONS.Context, &#39;useState&#39;&gt;) {
        const state = context.useState(LogsStateKey).get();
        if (!state) return;
        state.important = true;
    }
    // exposing this function for more versatile use. (link different ids to same log)
    export function logToSocket(
        context: Pick&lt;FUNCTIONS.Context, &#39;useState&#39;&gt;,
        { ids, line, ts }: { ids: string[]; ts?: number; line?: number },
        ...args: any[]
    ) {
        const state = context.useState(LogsStateKey).get();
        if (!state) return;
        state.logs.push({
            ids: ids,
            msg: stringifyLogs(null, args),
            ts: ts ?? Date.now(),
            line: line ?? state.logs.length,
        });
    }
    // set a state on context
    FUNCTIONS.DefaultContext.onCreate(function (context) {
        context.useState(LogsStateKey).set({ logs: [], important: false });
    });
    // store all the logs in the state
    FUNCTIONS.DefaultContext.onLog(function (context, args) {
        // every context.log will be mapped to 
        // ids=[context.id] 
        // so single id for all the context.log 
        logToSocket(context, { ids: [context.id] }, ...args);
    });
    // at end, check if it is important, and stream them to socket.
    FUNCTIONS.DefaultContext.onDispose(async function (context) {
        try {
            const state = context.useState(LogsStateKey).get();
            if (!state.important) return;
            for (const log of state.logs) socket.send(JSON.stringify(log));
        } catch (err) {
            console.error(&#39;Socket Log Error:&#39;, err);
        }
    });
</code></pre>
<ol start="7">
<li><p>Whenever a user logs in from the website, a third party, a device or a system-{type}, the login session gets its own UUID in the auth token.</p>
</li>
<li><p>Every request goes through an auth check. That is where I work out what kind of login it is and which categories the request belongs to.</p>
<pre><code class="language-typescript"> const WebsiteAuthValidator = ROUTES.Middleware.build({
     request: ROUTES.z.MiddlewareRequest({
         headers: z.object({}).passthrough(),
     }),
     response: ROUTES.z.MiddlewareResponse({
         headers: z.object({}).passthrough(),
     }),
     async func({ context, headers }) {
         // frontend wants to store logs!
         if (headers[&#39;x-store-logs&#39;] === &#39;true&#39;) needToSendLogsToSocket(context);
         const decoded = ...; // decode the token from headers.
         if (decoded.mode === &#39;client&#39;) throw createHttpError.Unauthorized(&#39;This is not a client Api, You only have access to client APIs&#39;);
         const ids = [context.id];
         if (decoded.mode === &#39;system&#39;) {
             if (decoded.id) ids.push(`Core:User:System:${decoded.id}`);
         } else {
             ids.push(`Core:User:${decoded.userId}`);
             ids.push(`Core:User:Org:${decoded.orgId}`);
             if (decoded.id) ids.push(`Core:User:${decoded.userId}:${decoded.id}`);
         }
         const { req } = context.useState(ExpressStateKey).get();
         logToSocket(context, { ids }, req.method, req.path);
         context.useState(DecodedStateKey).set(decoded);
         if (headers[&#39;x-store-logs&#39;] === &#39;true&#39;) return { headers: { &#39;x-request-id&#39;: context.id } };
         return {};
     },
 })
</code></pre>
<p>This way, when you get logs for <code>id = Core:User:2141</code>, you see a list of all the API calls made by this user. Each log includes a list of other relevant <code>ids</code>, and one of them will be <code>context.id</code>. That way, you can get the <code>requestId</code>, and by fetching the logs of that <code>requestId</code>, you get the complete logs for that specific API call.</p>
</li>
<li><p>When a request fails, we keep its logs.</p>
<pre><code class="language-typescript">const onError: ExpressHandler.onError = function ({ context, error: err }) {
  if (createHttpError.isHttpError(err)) {
    return { 
      body: err.message,
      headers: { &#39;x-request-id&#39;: context.id, ...(error.headers ?? {}) },
      status: err.statusCode
    };
  }
  if (context) {
    const { req } = context.useState(ExpressHandler.ExpressStateKey).get();
    // log the request, could be helpfull for recreating error, in debugging.
    context.log(
      &#39;Req::&#39;,
      JSON.stringify({ method: req.method, path: req.params, query: req.query, body: req.body, headers: req.headers }, null, &#39;\t&#39;),
    );
    context.log(&#39;Err While Req:&#39;, err);
    console.error(&#39;500 Error at:&#39;, context.id);
    // set the importance.
    needToSendLogsToSocket(context);
  } else {
    // nothing we can do further!
    console.error(&#39;Err:&#39;, err);
  }
  return { 
    body: &#39;Something went wrong!&#39;, 
    headers: { &#39;x-request-id&#39;: context.id }, 
    statusCode: 500 
  };
};
const v2 = ExpressHandler.serve({ bundle: ExposedRoutes, onError });
app.use(v2);
</code></pre>
</li>
</ol>
<h1>The log viewer</h1>
<p>I built the viewer with AI&#39;s help: I designed the system and let AI write the boring parts. Here it is:</p>
<p><img src="/assets/blog/implementing-request-based-logging-for-enhanced-debugging-in-leading-air-quality-companies/img1.png" alt=""></p>
<p>Later I needed a list of every email sent to one address, or every SMS sent to one phone number. All it took was a new template for <code>id</code>. Each of those logs carries the <code>requestId</code>, so you can see which request sent the email.</p>
<p><img src="/assets/blog/implementing-request-based-logging-for-enhanced-debugging-in-leading-air-quality-companies/img2.png" alt=""></p>
<p>Each log shows 4 things:</p>
<ol>
<li>A toggle button to see other linked <code>ids</code></li>
<li>The <code>line</code> number of the log</li>
<li>The <code>ts</code> in local time</li>
<li>The <code>msg</code>, which is the log message.</li>
</ol>
<p><img src="/assets/blog/implementing-request-based-logging-for-enhanced-debugging-in-leading-air-quality-companies/img3.png" alt=""></p>
<p>The extra <code>ids</code> are links, so you can jump from a user to a request to an email while reading.</p>
<hr>
<p>If you build something like this, build it yourself rather than buying it: the ids you will want to filter by are specific to your system.</p>
<p>How do you find the logs of one failed request in production today?</p>
]]></content:encoded>
</item>
<item>
  <title>One zod schema per route: Express handler, OpenAPI and TS client</title>
  <link>https://panth.vardayinitech.in/blog/a-guide-to-nodejs-generic-routing-system</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/a-guide-to-nodejs-generic-routing-system</guid>
  <pubDate>Wed, 11 Dec 2024 00:00:00 GMT</pubDate>
  <description>With @panth977/routes, each Node.js endpoint is declared once with zod request and response schemas. That one declaration is the typed handler Express serves, the OpenAPI JSON, and a generated TypeScript client. Middleware, plain HTTP and Server-Sent Events, with the code.</description>
  <category>http</category><category>node-js</category><category>routes</category><category>openapi</category><category>swagger</category><category>express-js</category>
  <content:encoded><![CDATA[<p>Each endpoint is declared once, with a zod schema for what comes in and what goes out. From that one declaration you get three things: the handler Express serves, the OpenAPI JSON for your docs, and a TypeScript client for the frontend.</p>
<p>The constraint: the declaration is not a server. <a href="https://jsr.io/@panth977/routes">@panth977/routes</a> only describes routes. Serving them is a separate handler, <a href="https://jsr.io/@panth977/routes-express">@panth977/routes-express</a> for Express, or one you write for another framework.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. <code>@panth977/routes</code> is one of my open-source libraries on JSR. This post walks through it with code: middleware, an HTTP route, an SSE route, and the calls that turn a folder of routes into docs and a client.</p>
<h2>What one declaration gives you</h2>
<table>
<thead>
<tr>
<th>From the route&#39;s schemas</th>
<th>Through</th>
</tr>
</thead>
<tbody><tr>
<td>A typed handler, served by Express</td>
<td><code>ROUTES.execute</code> + <code>@panth977/routes-express</code> (or your own handler)</td>
</tr>
<tr>
<td>OpenAPI JSON</td>
<td><code>ROUTES.getRouteDocJson</code>, built on <a href="https://www.npmjs.com/package/zod-openapi">zod-openapi</a></td>
</tr>
<tr>
<td>A TypeScript client for the frontend</td>
<td><code>ROUTES.CodeGen.genTsCode</code></td>
</tr>
</tbody></table>
<p>Because every route is already declared with types, the docs don&#39;t need a second source. The OpenAPI JSON can serve a docs UI, and the same JSON generates the frontend&#39;s code. Nothing is written twice.</p>
<p>Routes are built the same way as <a href="https://jsr.io/@panth977/functions">@panth977/functions</a>, so you keep its <code>wrappers</code>, <code>context</code>, <code>static</code>, <code>name</code> and <code>namespace</code>, <code>build</code>, and input/output schemas. The <a href="/blog/how-to-implement-type-safe-functions-in-javascript">functions post</a> covers those.</p>
<h2>Middleware</h2>
<p>A middleware declares its own request schema and its security rule. Both end up in the docs of every route that uses it.</p>
<pre><code class="language-typescript">const DecodedStateKey = FUNCTIONS.DefaultContextState.CreateKey&lt;ReturnType&lt;typeof decodeToken&gt;&gt;({ label: &#39;Decoded&#39;,scope: &#39;global&#39; });
const authorize = ROUTES.Middleware.build({
  security: {
    JwtAuth: {
      type: &quot;apiKey&quot;, // this rules will be used for documentation of routes
      description: &quot;Authorize ** /users/login **.&quot;,
      name: &quot;x-auth-token&quot;,
      in: &quot;header&quot;,
    },
  },
  request: ROUTES.z.MiddlewareRequest({ // this schema will be used to address route request schema, in documentation
    headers: z.object({ &quot;x-auth-token&quot;: z.string() }).passthrough(),
  }),
  response: ROUTES.z.MiddlewareResponse({}),
  async func({context, headers}) {
    const token =
      headers[&quot;x-auth-token&quot;] ??
      headers[&quot;authorized&quot;] ??
      headers[&quot;x-token&quot;];
    const decoded = await decodeToken(token);
    if (!decoded) throw createHttpError.Unauthorized(&quot;Token not found / Token got Expired / Invalid Token!&quot;);
    // set state to context for others to make use of it!
    context.useState(DecodedStateKey).set(decoded);
    return {};
  },
});
</code></pre>
<p>This one looks for the token in three headers (<code>x-auth-token</code>, then <code>authorized</code>, then <code>x-token</code>), decodes it, and throws a 401 if that fails. On success it stores the decoded token in the request&#39;s context under <code>DecodedStateKey</code>. Any route that lists <code>authorize</code> can read it back from there, which is what the next example does.</p>
<h2>An HTTP route</h2>
<pre><code class="language-typescript">const getProfile = ROUTES.Http.build([authorize], &quot;get&quot;, &quot;/profile&quot;, {
  description: `Get user profile.`,
  request: ROUTES.z.HttpRequest({
      query: z.object({
        getFriends: z.enum([&#39;true&#39;, &#39;false&#39;]).default(&#39;false&#39;),
      })
  }),
  response: ROUTES.z.HttpResponse({
    body: z.object({
      id: z.number(),
      username: z.string(),
      address: z.string().nullable(),
      noOfProjects: z.number().int().gt(0),
    }),
  }),
  static: {
    query: pg.query(`Select * from users where id = ? LIMIT 1`),
  },
  async func({context, query, build}) {
    const { userId } = context.useState(DecodedStateKey).get();
    const result = await build.query.execute([userId]);
    if (!result.rows.length) throw createHttpError.NotFound(&quot;Given user was not found in db, could be because someone has deleted the user.&quot;);
    if (query.getFriends === &#39;true&#39;) {...}
    return result.rows[0];
  },
});
</code></pre>
<p>The route reads the user id that <code>authorize</code> stored, runs a query declared once in <code>static</code>, and returns a row. The <code>getFriends</code> query parameter arrives as the string <code>&#39;true&#39;</code> or <code>&#39;false&#39;</code>, with <code>&#39;false&#39;</code> as the default, because that is what the schema says.</p>
<p>Compared with <code>@panth977/functions</code>, <code>input</code> becomes <code>request</code> and <code>output</code> becomes <code>response</code>. The request&#39;s <code>path</code>, <code>query</code>, <code>headers</code> and <code>body</code> arrive in <code>func</code> as named arguments. The response has <code>headers</code> and <code>body</code>.</p>
<p><code>ROUTES.z.HttpRequest</code> and <code>ROUTES.z.HttpResponse</code> stand in for <code>z.object</code> so the types stay exact. You can still pass your own. For the docs, a route also takes <code>description</code>, <code>tags</code>, <code>summary</code>, <code>security</code>, <code>reqMediaTypes</code> and <code>resMediaTypes</code>.</p>
<h2>A Server-Sent Events route</h2>
<p>An SSE route is an async generator. This one yields the request&#39;s logs 30 at a time until there are none left.</p>
<pre><code class="language-typescript">export const getLogs = ROUTES.Sse.build([systemAuthorized], &quot;get&quot;, &quot;/logs/{requestId}&quot;, {
  request: ROUTES.z.SseRequest({
    path: z.object({
      requestId: z.string(),
    }),
  }),
  response: ROUTES.z.SseResponse(
    z
      .object({ id: z.number(), msg: z.string(), ts: z.date() })
      .array()
      .transform((x) =&gt; JSON.stringify(x))
  ),
  async *func({context, path: { requestId }}) {
    let logs = [];
    let offset = 0;
    do {
      const result = await pg.query(`SELECT * FROM logs where id = ? ORDER BY ts OFFSET ? LIMIT 30`, [requestId, offset]);
      logs = result.rows
      offset += logs.length;
      yield logs;
    } while (logs.length);
  },
});
</code></pre>
<h2>Chaining middleware instead</h2>
<p>If you prefer <code>app.get(&#39;/profile&#39;, …)</code> with middleware chained in front, <code>ROUTES.Endpoint</code> gives you that shape.</p>
<pre><code class="language-typescript">const initMiddleware = ROUTES.Middleware.build(...);
const userAuthMiddleware = ROUTES.Middleware.build(...);
const thirdPartyAuthMiddleware = ROUTES.Middleware.build(...);

const EndpointFactory = ROUTES.Endpoint.build().addMiddleware(initMiddleware);
const endpoints = {
  userAuthorized: EndpointFactory.addMiddleware(userAuthMiddleware),
  thirdPartyAuthorized: EndpointFactory.addMiddleware(thirdPartyAuthMiddleware),
}

const getProfile = endpoints.userAuthorized.HTTP(&#39;get&#39;, &#39;/profile&#39;, ...);
const getProfile3rdParty = endpoints.thirdPartyAuthorized.get(&#39;/api/{userId}/profile&#39;, ...);
</code></pre>
<h2>From a folder of routes to OpenAPI</h2>
<p><code>ROUTES.getEndpointsFromBundle</code> takes a module&#39;s exports and keeps only the endpoints (HTTP and SSE), dropping helpers and middleware. The detailed types are lost at this step, which is what you want when you are serving or filtering every route at once.</p>
<pre><code class="language-typescript">// --- routes/index.ts ---
export * from &#39;./m.ts&#39;; // middleware
export * from &#39;./a.ts&#39;; // route1, route2, and a helper variable
export * from &#39;./b.ts&#39;; // route3, route4 (SSE), and a helper function
// --- server.ts ---
import * as routes_ from &#39;./routes/index.ts&#39;;
const routes = ROUTES.getEndpointsFromBundle({bundle: routes_}); // strong type will be lost
console.log(routes) // {route1: ..., route2: ..., route3: ..., route4: ...}
</code></pre>
<p>Then the OpenAPI JSON, with routes tagged <code>internal-apis</code> left out:</p>
<pre><code class="language-typescript">const routes = ROUTES.getEndpointsFromBundle({
    bundle: await import(&#39;./routes/index.ts&#39;),
    excludeTags: [&#39;internal-apis&#39;],
});
const OpenApiJson = ROUTES.getRouteDocJson(routes, {
    info: {
        title: &#39;My Apis&#39;,
        version: &#39;0.0.1&#39;,
    }
});
</code></pre>
<h2>Serving a route</h2>
<p><code>ROUTES.execute</code> runs one endpoint through lifecycle hooks you supply. This is the seam where a framework plugs in. The example uses Express; <a href="https://github.com/panth977-packages/routes-express">routes-express</a> is the full reference handler.</p>
<pre><code class="language-typescript">type MyFrameworkOpt = { req: Express.Request, res: Express.Response };
const MyEndpointExecutionLifeCycle: ROUTES.LifeCycle&lt;MyFrameworkOpt&gt; = {
    init(...) {...},
    onStatusChange({...}) {...},
    onExecution({...}) {...},
    onResponse({...}) {...},
    onComplete({...}) {...},
}
app.get(&#39;/getUser&#39;, (req, res) =&gt; {
    FUNCTIONS.DefaultContext.Builder.forTask(null, async (context, done) =&gt; {
        ROUTES
            .execute({
                context,
                build: getUserEndpoint,
                lc: MyEndpointExecutionLifeCycle,
                opt: { req, res },
            })
            .finally(done);
    })
})
</code></pre>
<h2>The TypeScript client</h2>
<p>The last output comes from the OpenAPI JSON. <code>ROUTES.CodeGen</code> turns it into frontend code with a typed call for every API. Here it is served from an endpoint:</p>
<pre><code class="language-typescript">app.get(&#39;/code-gen&#39;, (_, res) =&gt; {
  FUNCTIONS.DefaultContext.Builder.forTask(&#39;CodeGen&#39;, function (context, done) {
    res.on(&#39;finish&#39;, done);
    try {
      const { code } = ROUTES.CodeGen.genTsCode({ context, input: { json, options: {} } });
      res.setHeader(&#39;content-type&#39;, &#39;text/x.typescript&#39;).send(code);
    } catch (err) {
      console.log(err);
      res.status(500).send(&#39;Something went wrong!&#39;);
    }
  });
});
</code></pre>
<p>The schema is written once, and the handler, the docs and the client are all read from it.</p>
<p>The rest of the packages are at <a href="https://jsr.io/@panth977">jsr.io/@panth977</a>.</p>
<p>Where do your API docs come from today: the route code itself, or a spec someone keeps in step by hand?</p>
]]></content:encoded>
</item>
<item>
  <title>Zod input and output validation for JavaScript functions</title>
  <link>https://panth.vardayinitech.in/blog/how-to-implement-type-safe-functions-in-javascript</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/how-to-implement-type-safe-functions-in-javascript</guid>
  <pubDate>Sun, 08 Dec 2024 00:00:00 GMT</pubDate>
  <description>TypeScript types vanish at runtime. With @panth977/functions, a function declares zod schemas for its input and output, and a SafeParse wrapper checks them on every call. Wrappers as local middleware, static variables, and a context that carries logs, cleanup and state down the call tree.</description>
  <category>dispose</category><category>functional-programming</category><category>javascript</category><category>typescript</category><category>wrapper-functions</category><category>context</category>
  <content:encoded><![CDATA[<p>A function declares a zod schema for what it takes and what it returns, and a wrapper parses both on every call. The input and output are checked at runtime, not only in the editor.</p>
<p>The constraint: TypeScript types are for development. They don&#39;t validate a function&#39;s inputs or outputs once the code runs, so the checking has to be a layer you add on purpose. In <a href="https://jsr.io/@panth977/functions">@panth977/functions</a> that layer is <code>FUNCTIONS.WRAPPERS.SafeParse</code>.</p>
<p>I&#39;m Panth, and I lead the software team at Oizom. <code>@panth977/functions</code> is one of my open-source libraries on JSR. Once a function is declared this way, three other things come with the same shape: wrappers for caching and throttling without touching the function&#39;s code, static variables kept out of the global scope, and a context that travels through the call tree with logs, cleanup and state.</p>
<h2>One declaration</h2>
<pre><code class="language-typescript">const getUser = FUNCTIONS.AsyncFunction.build({
    namespace: &#39;PgQuery&#39;, // optional
    name: &#39;getUser&#39;, // optional
    input: z.object({ userId: z.number().int().gt(0) }),
    output: z.object({ name: z.string(), ... }).optional(),
    static: { // add static variables
        db: new Pg.Pool(...),
    },
    buildContext: YourCustomContextBuilder, // optional
    wrappers: (_params) =&gt; [FUNCTIONS.WRAPPERS.SafeParse({ _params })], // optional
    async func({ context, input, build }) {
        context.log(&#39;getting user&#39;, input.userId);
        const result = await build.db.query(`SELECT * FROM users WHERE id = ? LIMIT 1`, [input.userId]);
        const user = result.rows[0];
        if (!user) return undefined;
        context.log(&#39;found user&#39;, user.name);
        return user;
    }
});
...
getUser.setNamespace(&#39;PgQuery&#39;);
getUser.setName(&#39;getUser&#39;);
getUser.getRef(); // &#39;PgQuery.getUser&#39;
...
const user = await getUser({ context, input: { userId } });
</code></pre>
<p>What each part is for:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>input</code>, <code>output</code></td>
<td>zod schemas. Generators also get <code>yield</code> and <code>next</code>.</td>
</tr>
<tr>
<td><code>wrappers</code></td>
<td>Local middleware: higher-order functions, run in the order you write them.</td>
</tr>
<tr>
<td><code>static</code></td>
<td>Variables attached to the function, like <code>static</code> in a class. Read back through <code>build</code>.</td>
</tr>
<tr>
<td><code>buildContext</code></td>
<td>Your own context builder, or the default one.</td>
</tr>
<tr>
<td><code>name</code>, <code>namespace</code></td>
<td>For debugging. <code>getRef()</code> returns <code>&#39;PgQuery.getUser&#39;</code>.</td>
</tr>
<tr>
<td><code>func</code></td>
<td>The implementation, with <code>context</code>, <code>input</code> and <code>build</code>.</td>
</tr>
</tbody></table>
<p>There is a single argument, so every argument is named, and a call reads <code>getUser({ context, input: { userId } })</code> at every call site. <code>name</code> and <code>namespace</code> don&#39;t have to be known when the function is built; they can be set later in the code, which the last section uses. <code>FUNCTIONS</code> builds four kinds: <code>SyncFunction</code>, <code>AsyncFunction</code>, <code>SyncGenerator</code> and <code>AsyncGenerator</code>. The schemas give you types while you write the code. <code>SafeParse</code> is what makes them hold at runtime.</p>
<h2>Wrappers</h2>
<p>Caching, throttling and logging are usually higher-order functions wrapped around the original. Here they are a list on the declaration, and they run in the order written:</p>
<pre><code class="language-typescript">const fib = FUNCTIONS.SyncFunction.build({
    input: z.number(),
    output: z.number(),
    wrappers: (_params) =&gt; [
        // your custom wrapper
        function ({ context, input, func, build }) {
            console.log(&#39;start w1&#39;, input);
            const result = func({ context, input, build });
            console.log(&#39;end w1&#39;, input);
            return result;
        },
        function ({ context, input, func, build }) {
            console.log(&#39;start w2&#39;, input);
            const result = func({ context, input, build });
            console.log(&#39;end w2&#39;, input);
            return result;
        },
    ],
    func({ context, input, build }) {
        console.log(&#39;fib&#39;, input);
        if (input &lt;= 2) return 1;
        return build({ context, input: input - 1 }) + build({ context, input: input - 2 });
    },
});
fib({ context, input: 3 });
// CONSOLE
start w1 3
start w2 3
fib 3
start w1 2
start w2 2
fib 2
end w2 2
end w1 2
start w1 1
start w2 1
fib 1
end w2 1
end w1 1
end w2 3
end w1 3
</code></pre>
<p>The recursive calls go through <code>build</code>, so each one passes through both wrappers again. That is the point of having them on the declaration: <code>fib</code> itself has no idea that anything is wrapped around it, and you can add or remove a wrapper without editing <code>func</code>. Caching is a wrapper, the runtime check is a wrapper, and so is your own logging. The ready-made ones:</p>
<ul>
<li><code>FUNCTIONS.WRAPPERS.SafeParse</code> parses input and output.</li>
<li><code>FUNCTIONS.WRAPPERS.MemoData</code> remembers results in <code>AsyncFunction</code> and <code>SyncFunction</code>.</li>
<li><code>FUNCTIONS.WRAPPERS.Debug</code> logs input and output, and checks whether the call took longer than the throttle time you set.</li>
<li><code>FUNCTIONS.WRAPPERS.CloneData</code> copies inputs and outputs (and yields and next arguments in a generator).</li>
</ul>
<h2>Static variables</h2>
<p>Anything you put in <code>static</code> stays attached to the function, outside the global scope. A query builder, a connection pool, a constant the function needs: it lives with the function, the implementation reads it through <code>build</code>, and code outside can still reach it as a property:</p>
<pre><code class="language-typescript">const getUser = FUNCTIONS.AsyncFunction.build({
    ...
    static: {
        query: new QueryBuilder(`SELECT * FROM users WHERE ID = {{{SqlNum userId}}}`),
    },
    ...
    async func({ context, input, build }) {
        const rows = await pg.query(build.query.compile(input))
        ...
    }
});
getUser.query; // QueryBuilder
</code></pre>
<h2>Context: logs, cleanup and state down the call tree</h2>
<p>The <code>context</code> you pass in goes to <code>buildContext</code>, which makes a new one for the call. A context is identified by its <code>id</code>, and several contexts can share an <code>id</code>. I create one context with a unique id per task and pass it to every call under it.</p>
<pre><code class="language-typescript">await FUNCTIONS.DefaultContext.Builder.forTask(&#39;myId&#39;, function (context, done) {
    // use parent context as reference
    const context1 = FUNCTIONS.DefaultContext.Builder.fromParent(context, &#39;ref1&#39;);
    const context2 = FUNCTIONS.DefaultContext.Builder.fromParent(context, &#39;ref2&#39;);
    console.log(context.id) // &#39;myId&#39;
    console.log(context.id === context2.id) // true
    console.log(context === context2) // false
    console.log(context.path) // []
    console.log(context2.path) // [&#39;ref1&#39;, &#39;ref2&#39;]
    done();
}); // create a new context
</code></pre>
<p>A context made from a parent keeps the task&#39;s id and gets a new object, and its <code>path</code> records the references it came down through. Every call in one task logs and cleans up under the same id.</p>
<p><strong><code>context.log</code>.</strong> <code>console.log</code> can&#39;t tell you which task or thread wrote a line. <code>context.log</code> can. Nothing reaches the console until you add a logger:</p>
<pre><code class="language-typescript">FUNCTIONS.DefaultContext.onLog(function (context, args) {
    console.log(context.id, ...args);
});
// CONSOLE:
(0 sec) myId Hi World
</code></pre>
<p><strong><code>context.dispose</code>.</strong> Cleanup that shouldn&#39;t slow the main task goes into a dispose callback. Whoever created the context disposes of it:</p>
<pre><code class="language-typescript">await FUNCTIONS.DefaultContext.Builder.forTask(&#39;myId&#39;, function (context, done) {
    // just before this function is called, FUNCTIONS.DefaultContext.onCreate is called
    console.log(&#39;my implementation&#39;);
    context.dispose(() =&gt; TOOLS.delay(2_000).then(() =&gt; console.log(&#39;MyDispose1&#39;)));
    context.dispose(() =&gt; TOOLS.delay(2_000).then(() =&gt; console.log(&#39;MyDispose2&#39;)));
    TOOLS.delay(2_000).then(done);
    // after the done call, FUNCTIONS.DefaultContext.onDispose is called
});
console.log(&#39;task completed!&#39;);
</code></pre>
<p>The order: every <code>onCreate</code> hook is awaited (<code>Promise.allSettled</code>), then the task runs. When it calls <code>done</code>, every <code>context.dispose</code> callback is awaited, and then every <code>onDispose</code> hook.</p>
<p><strong><code>context.useState</code>.</strong> Local state belongs to one context instance. Global state is shared by all parent and child contexts. In the example, the child sees the parent&#39;s global value but not its local one, and when the child changes the global value, the parent sees the change:</p>
<pre><code class="language-typescript">const GlobalStateKey = FUNCTIONS.DefaultContextState.CreateKey&lt;string&gt;({
    label: &#39;MyGlobalState&#39;,
    scope: &#39;global&#39;,
});
const LocalStateKey = FUNCTIONS.DefaultContextState.CreateKey&lt;number&gt;({
    label: &#39;MyLocalState&#39;,
    scope: &#39;local&#39;,
});
FUNCTIONS.DefaultContext.Builder.forTask(&#39;myId&#39;, function (context, done) {
    context.useState(GlobalStateKey).set(&#39;s1&#39;);
    context.useState(LocalStateKey).set(1);
    const context1 = FUNCTIONS.DefaultContext.Builder.fromParent(context, &#39;ref1&#39;);
    const context2 = FUNCTIONS.DefaultContext.Builder.fromParent(context, &#39;ref2&#39;);
    console.log(context2.useState(GlobalStateKey).get()) // &#39;s1&#39;
    console.log(context2.useState(LocalStateKey).get()) // undefined
    context2.useState(LocalStateKey).set(2);
    context2.useState(GlobalStateKey).set(&#39;s2&#39;);
    console.log(context.useState(GlobalStateKey).get()) // &#39;s2&#39;
    console.log(context.useState(LocalStateKey).get()) // 1
    console.log(context2.useState(LocalStateKey).get()) // 2
    done();
});
</code></pre>
<h2>Names from the folder</h2>
<p><code>name</code> and <code>namespace</code> can be set after the build. I export every function in a directory from one <code>index.ts</code> and set them in a loop: the directory is the namespace, the export name is the name.</p>
<pre><code class="language-typescript">// --- pg/users.ts ---
export const getUser = FUNCTIONS.AsyncFunction.build({ ... });
export const getSubUsers = FUNCTIONS.AsyncFunction.build({ ... });
// --- pg/devices.ts ---
export const getDevices = FUNCTIONS.AsyncFunction.build({ ... });
export const updateDevice = FUNCTIONS.AsyncFunction.build({ ... });
// --- pg/_index.ts ---
export * from &#39;./users.ts&#39;;
export * from &#39;./devices.ts&#39;;
// --- pg/index.ts ---
export * as Pg from &#39;./_index.ts&#39;;
import * as _Pg_ from &#39;./_index.ts&#39;;
const Pg = (_Pg_) as Record&lt;string, any&gt;;
for (const name in Pg) {
    Pg[name].setNamespace(&#39;Pg&#39;);
    Pg[name].setName(name);
}
</code></pre>
<p>Routes in <a href="https://jsr.io/@panth977/routes">@panth977/routes</a> are built the same way, which the <a href="/blog/a-guide-to-nodejs-generic-routing-system">routing post</a> covers.</p>
<p>Do you parse a function&#39;s input and output at runtime, or only at the edges of the system, where requests come in?</p>
]]></content:encoded>
</item>
<item>
  <title>10 Javascript tools for clear code design</title>
  <link>https://panth.vardayinitech.in/blog/10-javascript-tools-for-clear-code-design</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/10-javascript-tools-for-clear-code-design</guid>
  <pubDate>Sat, 07 Dec 2024 00:00:00 GMT</pubDate>
  <description>Ten small utilities I reach for to keep JavaScript readable: batching, pub/sub, rate limiting, one-to-many relations, delays, connection strings, and a few others, each with the code.</description>
  <category>javascript</category><category>typescript</category><category>tools</category><category>event-loop</category><category>batch-processing</category><category>pubsub</category>
  <content:encoded><![CDATA[<p>Hey there, developer! You might need some tools to boost your skills, and here are a few to check out: (<a href="https://jsr.io/@panth977/tools">https://jsr.io/@panth977/tools</a>)</p>
<h3>Rate limit function invocation</h3>
<p>Sometimes you just want to make sure you don&#39;t go over the max number of calls to a service. To keep things in check and avoid hitting the rate limit, try using <code>TOOLS.CreateParallelTaskManager</code>.</p>
<pre><code class="language-typescript">const serviceToSq = TOOLS.CreateParallelTaskManager({
    maxParallelExecution: 2,
    async implementation(x) {
        console.log(&#39;start&#39;, x);
        await TOOLS.delay(x * 1000);
        console.log(&#39;end&#39;, x);
        return x ** 2;
    },
});
serviceToSq(5);
serviceToSq(2);
serviceToSq(4);
serviceToSq(10);
serviceToSq(9);
/** CONSOLE **/
(00 sec) start 5
(00 sec) start 2
(02 sec) end 2
(02 sec) start 4
(05 sec) end 5
(05 sec) start 10
(06 sec) end 4
(06 sec) start 9
(15 sec) end 10
(15 sec) end 9
</code></pre>
<h3>Batch multiple Invocations</h3>
<p>Maybe you&#39;ve got a speedy API call for the backend that handles multiple operations in one go, but you don&#39;t want to complicate your development process. In that case, try using <code>TOOLS.CreateBatchProcessor</code>.</p>
<pre><code class="language-typescript">const RedisMethods = {
    get: TOOLS.CreateBatchProcessor&lt;string, string | null&gt;({
        delayInMs: 0,
        async implementation(args) {
            console.log(&#39;&lt;0&gt;&#39;, args);
            return await redis.mget(args);
        },
    }),
    getAfter10ms: TOOLS.CreateBatchProcessor&lt;string, string | null&gt;({
        delayInMs: 10,
        async implementation(args) {
            console.log(&#39;&lt;10&gt;&#39;, args);
            return await redis.mget(args);
        },
    }),

}
RedisMethods.get(&#39;key1&#39;);
RedisMethods.get(&#39;key2&#39;);
delay(12).then(() =&gt; RedisMethods.get(&#39;key3&#39;));
delay(12).then(() =&gt; RedisMethods.get(&#39;key4&#39;));
RedisMethods.getAfter10ms(&#39;key5&#39;);
RedisMethods.getAfter10ms(&#39;key6&#39;);
delay(5).then(() =&gt; RedisMethods.getAfter10ms(&#39;key7&#39;));
delay(12).then(() =&gt; RedisMethods.getAfter10ms(&#39;key8&#39;));

/** CONSOLE **/
(00 ms) &lt;0&gt; [&#39;key1&#39;, &#39;key2&#39;]
(10 ms) &lt;0&gt; [&#39;key3&#39;, &#39;key4&#39;]
(10 ms) &lt;10&gt; [&#39;key5&#39;, &#39;key6&#39;, &#39;key7&#39;]
(22 ms) &lt;10&gt; [&#39;key8&#39;]
</code></pre>
<h3>Internal PubSub</h3>
<p>We&#39;ve all seen code where we pile on tons of features after creating a user. Sure, you could set up a PubSub, but that takes time. Unless you&#39;re handling something big, you can just do it on the API server. No need to set up a separate background worker service and build a whole PubSub messaging system. Try using <code>TOOLS.CreatePubsub</code> to keep your code nice and clear.</p>
<pre><code class="language-typescript">const userChanges = TOOLS.CreatePubsub(z.object({
    event: z.enum([&#39;CREATE&#39;, &#39;UPDATE&#39;, &#39;DELETE&#39;]),
    data: z.object({
        id: z.number().int(),
        email: z.string().email(),
        name: z.string().max(225),
        ...
    }),
}));
function createUser(userData) {
    const rows = pg.query(`INSERT INTO users VALUES (...) RETURNING *`);
    await userChanges.publish({ event: &#39;CREATE&#39;, data: rows[0] });
}
userChanges.subscribe(function ({ event, data }) {
    if (event === &#39;CREATE&#39;) {
        // send welcome email
    } else if (event === &#39;DELETE&#39;) {
        // send good bye email
    }
});
userChanges.subscribe(function () {
    if (event === &#39;CREATE&#39;) {
        // setup records
    } else if (event === &#39;DELETE&#39;) {
        // archive records
    }
});
</code></pre>
<h3>Data Structures One To One Mapping</h3>
<p>You often deal with databases that return rows, and you probably want a Dictionary structure to make life easier. We all know how annoying it is to use <code>.find</code> on rows every single time. Give <code>TOOLS.oneToOneMapping</code> a shot. We also have <code>TOOLS.oneToManyMapping</code> and <code>TOOLS.oneToOneToOneMapping</code>.</p>
<pre><code class="language-typescript">function getUsers(userIds) {
    const rows = pg.query(`SELECT * FROM users WHERE id IN (...)`)
    const users = TOOLS.oneToOneMapping({ rows, keyPath: &quot;id&quot; });
    return users;
}
const users = getUsers([userId1, userId2]);
const user1 = users[userId1];
const user2 = users[userId2];
</code></pre>
<h3>Data Structures One To Many Mapping</h3>
<pre><code class="language-typescript">function getUsersFriends(userIds) {
    const rows = pg.query(`
SELECT f.* 
FROM users u 
JOIN friends f ON f.userId = u.id
WHERE u.id IN (...)
    `)
    const friends = TOOLS.oneToManyMapping({ rows, keyPath: &quot;id&quot; });
    return friends;
}
const friends = getUsersFriends([userId1, userId2]);
const friendsOfUser1 = friends[userId1];
const friendsOfUser2 = friends[userId2];
</code></pre>
<h3>Data Structures One To One To Many Mapping</h3>
<p>I honestly doubt you&#39;ll ever need this monster! But hey, I&#39;ve got it here just in case. (NOTE: If you find yourself needing this in more than one place, you really should rethink your code architecture.)</p>
<h3>Encode Connection String</h3>
<p>Have you checked out <code>env.PG_HOST</code>, <code>env.PG_PORT</code>, <code>env.PG_DB</code>, <code>env.PG_USER</code>, <code>env.PG_PASS</code>? What if we could simplify all these different environment variables into a single <code>env.PG_CONNECTION_STRING</code>? That would be awesome! Here are the <code>TOOLS.encodeConnectionString</code> encoder and <code>TOOLS.decodeConnectionString</code> decoder for that.</p>
<pre><code class="language-typescript">MY_CONNECTION_STRING: TOOLS.encodeConnectionString({
    protocol: &#39;myprotocol&#39;,
    user: &#39;myuser&#39;,
    pass: &#39;mypass&#39;,
    host: &#39;myhost&#39;,
    port: 1234, // optional
    db: &#39;mydb&#39;, // optional
    params: { //optional
      log: &#39;someval&#39;,
      ssl: &#39;true&#39;,
      max: &#39;20&#39;,
    },
})
// myprotocol://myuser:mypass@myhost:1234/mydb?log=someval&amp;ssl=true&amp;max=20
MY_CONNECTION_OPTIONS: TOOLS.decodeConnectionString(MY_CONNECTION_STRING)
</code></pre>
<p>No worries, <code>user</code>, <code>pass</code>, and <code>host</code> are all encoded with <code>encodeURIComponent</code> and decoded with <code>decodeURIComponent</code>. Plus, <code>params</code> are handled with <code>URLSearchParams</code>, making life easier for everyone!</p>
<h3>Sort</h3>
<p>A quick sort function for an array to sort based on some deeply nested field, just the way you need it.</p>
<pre><code class="language-typescript">const myArray: {
    id: number;
    age: number; // int values only
    stats: {
        score: number;
        goals: number;
        ...
    };
    ...
}[] = [...];
const sortedOnScore = TOOLS.sortList({
    rows: myArray,
    mode: &#39;ASC&#39;,
    keyPath: &#39;stats.score&#39;,
});
const sortedOnAge = TOOLS.sortList({
    rows: myArray,
    mode: &#39;ASC&#39;,
    keyPath: &#39;age&#39;,
    algorithm: &#39;bucket&#39;,
});
</code></pre>
<h3>Dynamic destructuring</h3>
<p>You might need to grab a bunch of data from a SQL select query and turn it into a JSON for the frontend. Here&#39;s how you can do it easily using <code>TOOLS.destructure</code></p>
<pre><code class="language-typescript">const rows = await pg.query(`
    SELECT 
        v.id            &quot;videoId&quot;,
        c.email         &quot;creator.email&quot;,
        c.name          &quot;creator.name&quot;,
        c.handle        &quot;creator.handle&quot;,
        v.url           &quot;video.url&quot;,
        v.share_id      &quot;video.share_id&quot;,
        v.description   &quot;meta.description&quot;
        ...
    FROM ...
    JOIN ...
    WHERE ...
`);
const videosJson = TOOLS.destructure({ rows });
type Video = {
    videoId: number;
    creator: {
        email: string;
        name: string;
        handle: string;
    };
    video: {
        url: string; 
        share_id: string;
        description: string;
    };
    ...
}
return videosJson as Video[];
</code></pre>
<h3>All Promise Stats</h3>
<p>Let&#39;s say you&#39;re running a bunch of promises at the same time, and this is for a worker API. You probably want to track some stats to see which jobs were successful and which ones weren&#39;t. Here&#39;s how you can do it using <code>TOOLS.SettleAllPromise</code></p>
<pre><code class="language-typescript">const jobs: Record&lt;string, Promise&lt;any&gt;&gt; = {};
for (const cluster of clusters) {
    jobs[cluster.id] = runCluster(cluster);
}
const stats = TOOLS.SettleAllPromise(jobs);
stats.errors; // = Record&lt;string, errors&gt;
stats.results; // = Record&lt;string, errors&gt;
stats.stats; // {total, status, error?, errorRate?}
</code></pre>
<h3>Delay</h3>
<p>You&#39;ve got a super long sync process happening, and if you didn&#39;t know, JavaScript runs on a single thread. Unless you&#39;re using workers, you&#39;re stuck with just one core, and none of your incoming requests will get processed because that one thread is busy with a sync task.</p>
<p>If you&#39;re familiar with the JavaScript event loop, you know there&#39;s a way out. Just figure out your checkpoints where you can let the main thread handle other tasks and then come back to finish the rest of the process. <code>TOOLS.delay</code> comes to rescue.</p>
<pre><code class="language-typescript">function heavyTask1() {
    //
}
function heavyTask2() {
    //
}
function heavyTask3() {
    //
}
function typicalImplementation() {
    heavyTask1();
    heavyTask2();
    heavyTask3();
}
async function betterImplementation() {
    heavyTask1();
    // complete all the other task from task que &amp; event que
    await TOOLS.delay();
    heavyTask2();
    // complete all the other task from task que &amp; event que
    // if not already 250ms completed, wait of it to complete!
    await TOOLS.delay(250);
    heavyTask3();
}
</code></pre>
<hr>
<p>Check out <a href="https://jsr.io/@panth977">https://jsr.io/@panth977</a> to make your project clearly coded instead of that clean coding nonsense...</p>
]]></content:encoded>
</item>
<item>
  <title>😰 How is Backend (js)</title>
  <link>https://panth.vardayinitech.in/blog/how-is-backend-js</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/how-is-backend-js</guid>
  <pubDate>Sun, 01 Dec 2024 00:00:00 GMT</pubDate>
  <description>Node.js is probably your backend, and JavaScript gives you far more rope than most people use. A tour of the features that matter on the server, and the ones that quietly hurt.</description>
  <category>javascript</category><category>typescript</category>
  <content:encoded><![CDATA[<p>Unless you&#39;re a decades-old company or a big firm, you&#39;re probably using Node.js as your backend! It&#39;s unfortunate that people talk so much about various frontend JavaScript frameworks that they forget to mention the vast array of features available. 😰</p>
<p>Who in their right mind would think that <code>console.log({} &gt; [])</code> yields <code>true</code>? It&#39;s absurd because JavaScript decides to convert the object into a string before comparing. And that&#39;s why <code>console.log({} &gt; [&#39;x&#39;])</code> results in <code>false</code>. This kind of behavior is baffling and highlights the quirks of JavaScript.</p>
<p>The concept of the <code>prototype</code> and the <code>this</code> keyword can be absolutely mind-blowing 🤯! Imagine being able to change how an existing class method works (super handy when you don&#39;t have lifecycle hooks) or eliminating the need to create new extended classes. JavaScript is packed with powerful features for developers to explore, but only if you have the right knowledge 💪. Instead, you often see people relying on 💉 AI. AI isn&#39;t bad, but without a solid grasp of your concepts, AI can be like muscles made of oil—yikes!</p>
<pre><code class="language-javascript">class SomeClassFromLib {
    someMethod(...args) {
        return someVal;
    }
}
// this lib should have given you event for when property1 is called,
// but seems like [prototype] can fix this.
const original_someMethod = SomeClassFromLib.prototype.someMethod;
SomeClassFromLib.prototype.someMethod = function (...args) {
    console.log(&#39;someMethod was called.&#39;);
    return original_someMethod(...args);
}
// and now any instance of [SomeClassFromLib] class will use 
// this newly provided function.
</code></pre>
<p>You have <code>eval</code>, which is super powerful. You can do all sorts of cool stuff without needing to create a compiler. How about creating a context, passing it to all the function calls, collecting all the console logs in that context, and maybe storing them somewhere?</p>
<pre><code class="language-javascript">// all the functions exported from all the files.
const myFunc = {
  add: function (context, { a, b }) {
    const calc = a + b;
    console.log(calc);
    return calc;
  },
  diff: function (context, { a, b }) {
    const calc = Math.abs(a - b);
    console.log(calc);
    return calc;
  },
  calcAll: function (context, input) {
    return {
      add: myFunc.add(context, input),
      diff: myFunc.diff(context, input),
    };
  },
};
// wrap all the functions to magical functions that make sure
// every thing is [console.log] invokes [context.log]
for (const key in myFunc) {
  myFunc[key] = eval(`(function (context) {
    const originalConsole = console;
    $: {
      const console = {...originalConsole, log: (...args) =&gt; context.log(...args)}
      const func = ${myFunc[key].toString()};
      return func(...arguments);
    }
  })`)
}

const context = {
  logs: [],
  log(...args) {
    this.logs.push({ ts: Date.now(), log: JSON.stringify(args) });
  }
}
myFunc.add(context, {a: 2, b: 3})
myFunc.diff(context, {a: 2, b: 3})
myFunc.calcAll(context, {a: 5, b: 10})
console.log(context.logs);
/* Console */
[
  { ts: ..., log: &#39;[5]&#39; },
  { ts: ..., log: &#39;[1]&#39; },
  { ts: ..., log: &#39;[15]&#39; },
  { ts: ..., log: &#39;[5]&#39; }
]
</code></pre>
<p>Until I figure out a way to pass down context without code, you might need a compiler 😜. (Of course, you&#39;re now stuck with Error.stack or any outside references made.)</p>
<p>Then there&#39;s the event loop! You can totally use this to make space for other sync programs to run. How useful is that?! 🎉 This technique can help you share your main thread with other tasks throughout your app.</p>
<pre><code class="language-javascript">function syncLoop(x) {
    let s = 0;
    for (let i = 1; i &lt;= x; i++) {
        s += i;
    }
    console.log(s);
    return s;
}
syncLoop(1_000_000_000); // 1st
syncLoop(100); // 2ed

async function asyncLoop(x) {
  let s = 0;
  while (x &gt; 0) {
    if (x &lt; 10_000) {
      for (; x &gt; 0; x--) {
        s += x;
      }
    } else {
      const n = x - 10_000;
      for (; x &gt; n; x--) {
        s += x;
      }
      await new Promise((res) =&gt; res());
    }
  }
  console.log(s);
  return s;
}
asyncLoop(1_000_000_000); // 5th
asyncLoop(600); // 1st
asyncLoop(9_999); // 2ed
asyncLoop(10_001); // 4th
asyncLoop(200); // 3rd
asyncLoop(1_000_000_000); // 6th
</code></pre>
<p>But there&#39;s an obvious downside to offering so many features. There&#39;s no &quot;one way to code,&quot; and the human mind is creative, so it will find every possible way to use all these features in their scrap yard projects. When the beginners or interns continue working on the project, you end up discovering world-class misuse of features and classic coding pattern errors. This flexibility sometimes leads to such chaos.</p>
<p>Callback hell, indeed. JavaScript supports <code>async/await</code> syntax, yet for some inexplicable reason, and than they use <code>.then/.catch/callbacks</code> and keeps writing code in the callback function body. Why? Nothing good can come from this! And what&#39;s with all the <code>.catch</code> or <code>try/catch</code> everywhere? Seriously, unless you&#39;re actually handling the response, stop catching everything—it&#39;s not mandatory. And naturally, instead of using the <code>async function</code> syntax, they opt for <code>new Promise(...)</code> syntax, as if that simplifies anything.</p>
<pre><code class="language-javascript">function badImplementation() {
    return new Promise((resolve, reject) =&gt; {
        someOldLibWithCb((data, err) =&gt; {
            if (err) {
                reject(err);
                return;
            }
            myDbCall(data).then((result) =&gt; {
                const output = processResult(result);
                resolve(output);
            }).catch(reject);
        });
    });
}
// OR
async function goodImplementation() {
    // Convert any old cb syntax to promise using [new Promise]
    const data = await new Promise((resolve, reject) =&gt; {
        try { // put a try/catch just to be extra safe.
            someOldLibWithCb((data, err) =&gt; {
                if (err) reject(err);
                else resolve(data);
            });
        } catch (err) {
            reject(err);
        }
    });
    // keep awaiting, and don&#39;t catching unless you have a reason to.
    const result = await myDbCall(data);
    const output = processResult(result);
    return output;
}
</code></pre>
<p>We can chat all day about clean code and whether it&#39;s better or not, but honestly, I think it&#39;s kind of pointless. This isn&#39;t about clean code; it&#39;s about Clear Code. To achieve that, you need to decide how your code should look and which features are most important. Otherwise, we&#39;re no better than the next desk intern! 😰</p>
<hr>
<p>In my future blogs, I will share a JS design system I have created, specifically for the backend! This system can greatly enhance your debugging and coding time, while ensuring your code is type-safe and free from runtime type errors. Let&#39;s build a community focused on code design! We&#39;ll make JS code design so great that AI will be all over it!</p>
]]></content:encoded>
</item>
<item>
  <title>10 Common Issues in Node.js to keep in Mind</title>
  <link>https://panth.vardayinitech.in/blog/10-common-issues-in-nodejs-to-keep-in-mind</link>
  <guid isPermaLink="true">https://panth.vardayinitech.in/blog/10-common-issues-in-nodejs-to-keep-in-mind</guid>
  <pubDate>Sun, 01 Dec 2024 00:00:00 GMT</pubDate>
  <description>Ten mistakes I keep seeing in Node.js code, mostly around promises and validation: floating promises, try without await, unhandled rejections, parsing without zod, and blocking the event loop.</description>
  <category>node-js</category><category>promises</category><category>zod</category><category>asynchronous</category><category>trycatch</category><category>typescript</category>
  <content:encoded><![CDATA[<h4>If you have a callback API function, fix it using the Promise constructor.</h4>
<pre><code class="language-javascript">function badImplementation() {
    return new Promise((resolve, reject) =&gt; {
        someOldLibWithCb((data, err) =&gt; {
            if (err) {
                reject(err);
                return;
            }
            // Implementation...
        });
    });
}
// OR
async function goodImplementation() {
    // Convert any old cb syntax to promise using [new Promise]
    const data = await new Promise((resolve, reject) =&gt; {
        try { // put a try/catch just to be extra safe.
            someOldLibWithCb((data, err) =&gt; {
                if (err) reject(err);
                else resolve(data);
            });
        } catch (err) {
            reject(err);
        }
    });
    // Implementation...
}
</code></pre>
<h4>Keep your function implementation in the main body instead of inside a callback.</h4>
<pre><code class="language-javascript">async function badImplementation() {
    return getUser(userId).then((user) =&gt; {
        if (!user) throw new Error(&#39;User not found&#39;);
    });
}
// OR
async function goodImplementation() {
    const user = await getUser(userId);
    if (!user) throw new Error(&quot;User not found&quot;);
    // ...
}
</code></pre>
<h4>Make sure to avoid catching errors unless you have a plan to handle them.</h4>
<pre><code class="language-javascript">async function badImplementation() {
    const user = getUser(userId).catch((err) =&gt; {console.error(err)});
    if (!user) throw &quot;User not found&quot;;
    // ...
}
// OR
async function goodImplementation() {
    const user = await getUser(userId);
    // ...
}
</code></pre>
<h4>Always throw an HTTP response-supported error for better API responses.</h4>
<pre><code class="language-javascript">class HttpError extends Error {
  constructor(status, message) {
    this.status = status;
    super(message);
  }
}
app.get(&#39;/profile&#39;, async (req, res) =&gt; {
    try {
        const profile = await getProfile(req.query.username);
        res.send(profile);
    } catch (err) 
        if (err instanceof HttpError) {
            res.status(err.code).send(err.message);
            return;
        }
        console.error(err);
        res.status(500).send(&quot;Something Went Wrong!&quot;);
    }
});
async function getProfile(username) {
    const user = await getUser(username);
    if (!user) throw new HttpError(400, &quot;No such user found!&quot;);
    // implementation
    return profile;
}
</code></pre>
<h4>Consider sending a request ID with all incoming requests and storing all <code>console.log</code> entries in a database for debugging purposes.</h4>
<pre><code class="language-javascript">function myLog(requestId, ...args) {
    try {
        await db.logs.insert(requestId, Date.now(), JSON.stringify(args));
    } catch (err) {
        cosnole.error(err);
    }
}
app.get(&#39;/profile&#39;, async (req, res) =&gt; {
    const requestId = randomUuid();
    const profile = await getProfile(requestId, req.query.username);
    res.setHeaders(&#39;x-req-id&#39;, requestId);
    res.send(profile);
});
async function getProfile(requestId, username) {
    const user = await getUser(username);
    if (!user) throw new HttpError(400, &quot;No such user found!&quot;);
    // implementation
    myLog(requestId, &quot;Found Profile&quot;, username, profile);
    return profile;
}
</code></pre>
<h4>Add schema validation for all incoming requests and outgoing responses, as they cannot be fully trusted and might have a schema version mismatch.</h4>
<pre><code class="language-javascript">app.get(&#39;/user&#39;, async (req, res) =&gt; {
    if (!isUsername(req.query.username)) {
        res.status(400).send(&#39;Invalid username!&#39;)
        return;
    }
    const user = await getUser(req.query.username);
    if (!isUsername(user.username)) {
        res.status(500).send(&#39;Invalid username!&#39;)
        return;
    }
    if (!isEmail(user.email)) {
        res.status(500).send(&#39;Invalid email!&#39;)
        return;
    }
    if (!isName(user.name)) {
        res.status(500).send(&#39;Invalid name!&#39;)
        return;
    }
    res.send(user);
});
app.post(&#39;/user&#39;, async (req, res) =&gt; {
   if (!isUsername(req.body.username)) {
        res.status(400).send(&#39;Invalid username!&#39;)
        return;
    }
    if (!isEmail(req.body.email)) {
        res.status(400).send(&#39;Invalid email!&#39;)
        return;
    }
    if (!isName(req.body.name)) {
        res.status(400).send(&#39;Invalid name!&#39;)
        return;
    }
    const profile = await getProfile(requestId, req.query.username);
    res.send(&#39;User added successfully!&#39;);
});
</code></pre>
<h4>Cache your frequently accessed data. There&#39;s no need to cache everything!</h4>
<pre><code class="language-javascript">async function getDevices(userId) {
    // maybe cache this!
    return await db.user(userId).devices.get();
}
async function getDevicesData(userId, gte, lte) {
    const devices = await getDevices(userId)
    return await db.devices(devices).data(gte, lte);
}
async function getDevicesStatus(userId) {
    const devices = await getDevices(userId)
    return await db.devices(devices).data.lastEntry().wasInLast15Min();
}
async function getDevicesExpiry(userId) {
    const devices = await getDevices(userId)
    // ...
}
// Make sure to invalidate the cache when changes are made!
</code></pre>
<h4>Create an internal Pub/Sub system; it can be useful. Also, consider creating a batching function and a max parallel allowance function, similar to Pub/Sub. These can make your work easier.</h4>
<pre><code class="language-javascript">class PubSub {
  cbs = new Set()
  subscribe(cb) {
      this.cbs.add(cb);
      return () =&gt; {
          this.cbs.delete(cb);
      }
  }
  publish(...args) {
      for (const cb of this.cbs) {
          setTimeout(() =&gt; cb(...args));
      }
  }
}

const userChanges = new PubSub();
userChanges.subscribe((username) =&gt; {
  // Invalidate user cache
})
async function getUser(username) {
    // cache this!
}
async function updateUser(username) {
    // Implementation
    userChanges.publish(username)
}
async function deleteUser(username) {
    // Implementation
    userChanges.publish(username)
}
</code></pre>
<h4>You might want a way to generate OpenAPI JSON, and there are various UIs available to assist with presentation.</h4>
<pre><code class="language-javascript">const generatedOpenAPIjson = {...};
app.use(&#39;/docs/&#39;, swagger.ui, swagger.serve(generatedOpenAPIjson));
</code></pre>
<h4>Ensure all your <code>GET</code> APIs are fast, and make your <code>POST</code>, <code>PATCH</code>, <code>DELETE</code>, and <code>PUT</code> operations transactional. This means that either everything succeeds or everything fails.</h4>
<hr>
<p>I think I&#39;ve found a way to handle these problems. Check out <a href="https://jsr.io/@panth977">https://jsr.io/@panth977</a> for how to use it. It&#39;s the best approach for building a backend easily.</p>
<p>This package lets you do many things easily.</p>
<ol>
<li><a href="https://jsr.io/@panth977/tools">https://jsr.io/@panth977/tools</a> offers useful tools, such as creating internal Pub/Subs and higher-order functions that let you batch multiple function calls. It also includes a higher-order function to set a limit on the maximum number of parallel invocations allowed, as well as one-to-one and one-to-many mappings, among others.</li>
<li><a href="https://jsr.io/@panth977/functions">https://jsr.io/@panth977/functions</a> offers a builder to create type-safe functions using Zod, along with static variables and local wrappers. You can build synchronous functions, asynchronous functions, synchronous generators, and asynchronous generators. It also includes helpful wrapper implementations for easier use.</li>
<li><a href="https://jsr.io/@panth977/cache">https://jsr.io/@panth977/cache</a> offers a standard cache class, giving you full control over key prefixes, and the ability to read, write, and remove keys and hash fields. It also includes standard hooks to cache a response, whether it&#39;s an Object, MultipleObject, Collection, or MultipleCollection. This should cover all your caching needs.</li>
<li><a href="https://jsr.io/@panth977/routes">https://jsr.io/@panth977/routes</a> provides builders to create your endpoints. It supports Middleware, HTTP, and SSE techniques. If you correctly use the Zod schema with the routes, it will automatically generate OpenAPI JSON for free.</li>
<li><a href="https://jsr.io/@panth977/cache-redis">https://jsr.io/@panth977/cache-redis</a> provides a Redis implementation for <a href="https://jsr.io/@panth977/cache">/cache</a>.</li>
<li><a href="https://jsr.io/@panth977/routes-express">https://jsr.io/@panth977/routes-express</a> provides an Express implementation for <a href="https://jsr.io/@panth977/routes">/routes</a>.</li>
</ol>
<p>Stay tuned for more insights.</p>
]]></content:encoded>
</item>
</channel>
</rss>