<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: nareshipme</title>
    <description>The latest articles on Forem by nareshipme (@nareshipme).</description>
    <link>https://forem.com/nareshipme</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3824471%2F4940ab6f-46ad-4ee3-a988-83f89f976689.png</url>
      <title>Forem: nareshipme</title>
      <link>https://forem.com/nareshipme</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/nareshipme"/>
    <language>en</language>
    <item>
      <title>Adding YouTube thumbnails and real titles to existing projects — without a migration</title>
      <dc:creator>nareshipme</dc:creator>
      <pubDate>Sun, 03 May 2026 11:54:08 +0000</pubDate>
      <link>https://forem.com/nareshipme/adding-youtube-thumbnails-and-real-titles-to-existing-projects-without-a-migration-4jmf</link>
      <guid>https://forem.com/nareshipme/adding-youtube-thumbnails-and-real-titles-to-existing-projects-without-a-migration-4jmf</guid>
      <description>&lt;p&gt;Sometimes the best UX fix is the one that requires no new infrastructure. Here's how we added YouTube thumbnails and real video titles to &lt;a href="https://getclipcrafter.com" rel="noopener noreferrer"&gt;ClipCrafter&lt;/a&gt; project cards — using only what was already in the database and what YouTube freely provides.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Project cards on the dashboard looked like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Title: &lt;code&gt;YouTube video (8fcJ4Y-uxCw)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Thumbnail: none&lt;/li&gt;
&lt;li&gt;Status badge, timestamp, action buttons&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not great. Users couldn't tell their projects apart at a glance. The fix seemed obvious — store the real title and a thumbnail URL. But that would mean:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A database migration to add a &lt;code&gt;thumbnail_url&lt;/code&gt; column&lt;/li&gt;
&lt;li&gt;A backfill for existing rows&lt;/li&gt;
&lt;li&gt;Storing the title at upload time&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We did none of those things.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fix 1: Real titles via YouTube's oEmbed API
&lt;/h2&gt;

&lt;p&gt;YouTube's oEmbed endpoint returns video metadata including the title — &lt;strong&gt;no API key required&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=VIDEO_ID&amp;amp;format=json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The actual video title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"author_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Channel Name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"thumbnail_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://i.ytimg.com/vi/.../hqdefault.jpg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We call this during project creation, before inserting to the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchYoutubeTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`https://www.youtube.com/oembed?url=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;format=json`&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;submitYoutubeUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ytMatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;v=|youtu&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;be&lt;/span&gt;&lt;span class="se"&gt;\/)([&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9_-&lt;/span&gt;&lt;span class="se"&gt;]{11})&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ytMatch&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`YouTube video (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ytMatch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchYoutubeTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// title is now "How to Build a Rocket" instead of "YouTube video (dQw4w9WgXcQ)"&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createProject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;youtube&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;youtubeUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The fallback is always safe.&lt;/strong&gt; If the oEmbed call fails (private video, network error, rate limit), we fall back to the ID-based title. The user flow never breaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The title is stored, not fetched on read.&lt;/strong&gt; This means no repeated API calls and the title survives even if the video is later deleted or made private.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No API key.&lt;/strong&gt; YouTube's oEmbed endpoint is publicly accessible. It works for any public video.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Fix 2: Thumbnails from a predictable URL pattern
&lt;/h2&gt;

&lt;p&gt;YouTube serves thumbnails at deterministic URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://img.youtube.com/vi/{VIDEO_ID}/mqdefault.jpg  // 320×180
https://img.youtube.com/vi/{VIDEO_ID}/hqdefault.jpg  // 480×360
https://img.youtube.com/vi/{VIDEO_ID}/maxresdefault.jpg // max resolution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No API call needed — just the video ID. The question was: how do we get the video ID from our stored data?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For new projects&lt;/strong&gt;, we store the normalized YouTube URL in &lt;code&gt;r2_key&lt;/code&gt; (e.g. &lt;code&gt;https://www.youtube.com/watch?v=8fcJ4Y-uxCw&lt;/code&gt;), so extracting the ID is straightforward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For old projects&lt;/strong&gt;, the stored &lt;code&gt;r2_key&lt;/code&gt; is the R2 object path of the downloaded video file (&lt;code&gt;videos/{uuid}/video.mp4&lt;/code&gt;) — no video ID there. But old projects have a predictable title format: &lt;code&gt;YouTube video (8fcJ4Y-uxCw)&lt;/code&gt;. The ID is right there in the title.&lt;/p&gt;

&lt;p&gt;This gave us a two-path function with a graceful fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getYoutubeThumbnail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Project&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;youtube&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// New projects: r2_key is the YouTube URL&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fromKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;r2_key&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;?&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;v=&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9_-&lt;/span&gt;&lt;span class="se"&gt;]{11})&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fromKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`https://img.youtube.com/vi/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fromKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/mqdefault.jpg`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Old projects: r2_key is a storage path, but title has the ID&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fromTitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\(([&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9_-&lt;/span&gt;&lt;span class="se"&gt;]{11})\)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fromTitle&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`https://img.youtube.com/vi/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fromTitle&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/mqdefault.jpg`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No migration. No backfill script. Every existing project gets thumbnails automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wiring it into the card
&lt;/h2&gt;

&lt;p&gt;The project card uses Next.js &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt; with the YouTube thumbnail domain added to &lt;code&gt;next.config.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;remotePatterns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;img.youtube.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The card component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;YoutubeThumbnail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Project&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getYoutubeThumbnail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"relative w-full aspect-video"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt;
        &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;
        &lt;span class="na"&gt;fill&lt;/span&gt;
        &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"object-cover"&lt;/span&gt;
        &lt;span class="na"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"&lt;/span&gt;
        &lt;span class="na"&gt;unoptimized&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProjectCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;ProjectCardProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;YoutubeThumbnail&lt;/span&gt; &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"p-4 flex flex-col gap-3"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* title, status, timestamp, actions */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;unoptimized&lt;/code&gt; is set because YouTube's CDN already serves optimized images — running them through Next.js image optimization would be redundant.&lt;/p&gt;




&lt;h2&gt;
  
  
  The broader pattern
&lt;/h2&gt;

&lt;p&gt;Before reaching for a migration or a new API integration, ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is the data already in the database?&lt;/strong&gt; The video ID was in the title all along.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the platform expose it for free?&lt;/strong&gt; YouTube's oEmbed gives us the real title with no authentication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can the URL be derived deterministically?&lt;/strong&gt; Thumbnail URLs follow a predictable pattern once you have the ID.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Data you already have, combined with free platform APIs and predictable URL structures, can cover a lot of ground before you need to store anything new.&lt;/p&gt;




&lt;p&gt;We're building &lt;a href="https://getclipcrafter.com" rel="noopener noreferrer"&gt;ClipCrafter&lt;/a&gt; — AI-powered video clip extraction for creators. If you found this useful, follow along for more engineering notes.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>ux</category>
    </item>
    <item>
      <title>Three CI failures we fixed this week (and what each one taught us)</title>
      <dc:creator>nareshipme</dc:creator>
      <pubDate>Sun, 03 May 2026 11:53:20 +0000</pubDate>
      <link>https://forem.com/nareshipme/three-ci-failures-we-fixed-this-week-and-what-each-one-taught-us-end</link>
      <guid>https://forem.com/nareshipme/three-ci-failures-we-fixed-this-week-and-what-each-one-taught-us-end</guid>
      <description>&lt;p&gt;A green CI pipeline is a contract. When it breaks in ways that differ from your local environment, something in the contract is wrong — not the code. Here are three failures we debugged this week on &lt;a href="https://getclipcrafter.com" rel="noopener noreferrer"&gt;ClipCrafter&lt;/a&gt; and the underlying problems each exposed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure 1: CI fails typecheck, local passes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What we saw
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="nx"&gt;TS2345&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Argument&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{ quality: number; }&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;not&lt;/span&gt; &lt;span class="nx"&gt;assignable&lt;/span&gt;
&lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;parameter&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ExportOptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
  &lt;span class="nx"&gt;Types&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;property&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quality&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="nx"&gt;are&lt;/span&gt; &lt;span class="nx"&gt;incompatible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="nx"&gt;Type&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;not&lt;/span&gt; &lt;span class="nx"&gt;assignable&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;QualityPreset | undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passed locally. Failed in CI. Classic.&lt;/p&gt;

&lt;h3&gt;
  
  
  The investigation
&lt;/h3&gt;

&lt;p&gt;First instinct: the code changed the type. But &lt;code&gt;quality&lt;/code&gt; was already a string in our code — we were passing &lt;code&gt;"medium"&lt;/code&gt; or &lt;code&gt;"original"&lt;/code&gt;. So why was CI complaining about &lt;code&gt;number&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;The error message says &lt;code&gt;QualityPreset&lt;/code&gt; — a type that didn't exist in our local type definitions at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;node_modules/framewebworker/package.json | &lt;span class="nb"&gt;grep &lt;/span&gt;version
  &lt;span class="s2"&gt;"version"&lt;/span&gt;: &lt;span class="s2"&gt;"0.4.0"&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;package.json | &lt;span class="nb"&gt;grep &lt;/span&gt;framewebworker
  &lt;span class="s2"&gt;"framewebworker"&lt;/span&gt;: &lt;span class="s2"&gt;"^0.5.5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Local &lt;code&gt;node_modules&lt;/code&gt; was 0.4.0. CI installs fresh from npm every run and got 0.5.5. The library changed &lt;code&gt;quality&lt;/code&gt; between versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 0.4.0 — quality is a number (0-1)&lt;/span&gt;
&lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 0.5.5 — quality is a named preset&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;QualityPreset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;original&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nl"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;QualityPreset&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We had already written &lt;code&gt;quality: "medium"&lt;/code&gt; — which is correct for 0.5.5. But with 0.4.0 installed locally, the compiler accepted &lt;code&gt;number&lt;/code&gt; too loosely.&lt;/p&gt;

&lt;h3&gt;
  
  
  The wrong fix we almost made
&lt;/h3&gt;

&lt;p&gt;We initially mapped the string to a number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ wrong — fighting the type instead of fixing the environment&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;original&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This made local typecheck pass — but would have broken CI even harder.&lt;/p&gt;

&lt;h3&gt;
  
  
  The actual fix
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;  &lt;span class="c"&gt;# update node_modules to match package.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With 0.5.5 installed locally, our original string values were already correct. No code change needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The lesson
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;When CI typecheck fails but local passes, suspect the environment before the code.&lt;/strong&gt; Check &lt;code&gt;node_modules&lt;/code&gt; version vs &lt;code&gt;package.json&lt;/code&gt; declared version. A stale install is invisible until CI runs fresh.&lt;/p&gt;

&lt;p&gt;Add this to your debugging checklist before changing code to satisfy a type error:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the installed version what &lt;code&gt;package.json&lt;/code&gt; declares?&lt;/li&gt;
&lt;li&gt;Is the CI environment installing the same lockfile?&lt;/li&gt;
&lt;li&gt;Did a transitive dependency change?&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Failure 2: Worker sync check fails after billing changes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What we saw
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ Worker files are out of sync with src/.
   Run: bash scripts/sync-worker.sh
   Then commit the changes in worker/src/.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The context
&lt;/h3&gt;

&lt;p&gt;The project runs a separate worker process alongside the Next.js app. Certain lib files are shared between them, but the worker uses relative imports instead of path aliases (&lt;code&gt;@/lib/billing&lt;/code&gt; → &lt;code&gt;./billing&lt;/code&gt;). Rather than maintaining two copies by hand, a sync script rewrites imports and copies the files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scripts/sync-worker.sh (simplified)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;supabase r2 billing transcribe highlights&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s|from "@/lib/\([^"]*\)"|from "./\1"|g'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    src/lib/&lt;span class="nv"&gt;$f&lt;/span&gt;.ts &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; worker/src/lib/&lt;span class="nv"&gt;$f&lt;/span&gt;.ts
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CI diffs the expected output against the committed &lt;code&gt;worker/src/&lt;/code&gt; files. Any divergence fails the build.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happened
&lt;/h3&gt;

&lt;p&gt;We'd updated &lt;code&gt;src/lib/billing.ts&lt;/code&gt; as part of the billing hardening work and didn't run the sync script before pushing. The worker copy was one commit behind.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash scripts/sync-worker.sh
git add worker/src/lib/billing.ts
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"chore: sync worker billing"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The lesson
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Automated sync checks are worth the setup cost.&lt;/strong&gt; Without the CI check, the worker would have silently run old billing code while the app used the new atomic RPC call — a subtle, hard-to-debug divergence. The check makes the implicit dependency explicit and enforced.&lt;/p&gt;

&lt;p&gt;If you have files that need to stay in sync across boundaries (monorepo packages, generated types, worker copies), automate both the sync &lt;em&gt;and&lt;/em&gt; the verification.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure 3: Linter flags complexity after a refactor
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What we saw
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error  Async function 'POST' has a complexity of 13. Maximum allowed is 10  complexity
error  Async function 'POST' has a complexity of 11. Maximum allowed is 10  complexity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The context
&lt;/h3&gt;

&lt;p&gt;The project enforces &lt;code&gt;complexity: ["error", 10]&lt;/code&gt; in ESLint — cyclomatic complexity measures the number of independent paths through a function. Each &lt;code&gt;if&lt;/code&gt;, &lt;code&gt;else if&lt;/code&gt;, &lt;code&gt;||&lt;/code&gt;, &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;try/catch&lt;/code&gt;, and ternary adds one.&lt;/p&gt;

&lt;p&gt;A refactor had added idempotency checking (a &lt;code&gt;try/catch&lt;/code&gt; + an &lt;code&gt;if&lt;/code&gt;) and plan ID mapping (a three-way condition) directly into two &lt;code&gt;POST&lt;/code&gt; route handlers. Both tipped over 10.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: extract the decision logic
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Webhook handler&lt;/strong&gt; — moved idempotency check into its own function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: all inline in POST, complexity = 13&lt;/span&gt;
&lt;span class="c1"&gt;// After: extracted, POST complexity drops to 8&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isDuplicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webhook_events&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;23505&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Upgrade handler&lt;/strong&gt; — extracted the plan validation guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: three-way comparison inline, adds 2 complexity points&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;starter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unlimited&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// After: extracted to a type guard&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isValidPlan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;ValidPlan&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;starter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unlimited&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The lesson
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Complexity limits force good decomposition.&lt;/strong&gt; The &lt;code&gt;isDuplicate&lt;/code&gt; function is now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Named (self-documenting)&lt;/li&gt;
&lt;li&gt;Independently testable&lt;/li&gt;
&lt;li&gt;Reusable if a second handler needs the same check&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that would have happened without the linter pushing back. Cyclomatic complexity rules feel annoying until you're reading someone else's 200-line function with 15 nested conditions.&lt;/p&gt;

&lt;p&gt;If your project doesn't have one, consider adding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;.eslintrc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;or&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;eslint.config.mjs&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"complexity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start with 15 if 10 feels too aggressive — the discipline it enforces is worth it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The common thread
&lt;/h2&gt;

&lt;p&gt;All three failures had the same shape: &lt;strong&gt;a local environment that hid a real problem&lt;/strong&gt;. Stale &lt;code&gt;node_modules&lt;/code&gt; hid a version mismatch. No sync check would have hidden a worker divergence. No complexity rule would have hidden spaghetti accumulating in route handlers.&lt;/p&gt;

&lt;p&gt;CI's job is to be stricter than your local setup. When it catches something your machine didn't, that's it working correctly.&lt;/p&gt;




&lt;p&gt;We build &lt;a href="https://getclipcrafter.com" rel="noopener noreferrer"&gt;ClipCrafter&lt;/a&gt; — AI-powered video clip extraction. If CI war stories resonate, follow along.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>typescript</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Two billing bugs that looked fine until production proved otherwise</title>
      <dc:creator>nareshipme</dc:creator>
      <pubDate>Sun, 03 May 2026 11:43:02 +0000</pubDate>
      <link>https://forem.com/nareshipme/two-billing-bugs-that-looked-fine-until-production-proved-otherwise-1nme</link>
      <guid>https://forem.com/nareshipme/two-billing-bugs-that-looked-fine-until-production-proved-otherwise-1nme</guid>
      <description>&lt;p&gt;Billing code is the most dangerous place to have subtle bugs. It rarely crashes — it just silently does the wrong thing. Here are two we found and fixed in &lt;a href="https://getclipcrafter.com" rel="noopener noreferrer"&gt;ClipCrafter&lt;/a&gt;, an AI video clip extraction tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 1: The usage counter that lost data under load
&lt;/h2&gt;

&lt;p&gt;We track how many seconds of video each user processes per day to enforce plan limits. The original increment looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;daily_usage_seconds&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clerk_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clerkUserId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;daily_usage_seconds&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;daily_usage_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clerk_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clerkUserId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a textbook read-modify-write race. Here's the scenario that breaks it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request A reads &lt;code&gt;daily_usage_seconds = 120&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Request B reads &lt;code&gt;daily_usage_seconds = 120&lt;/code&gt; (before A has written)&lt;/li&gt;
&lt;li&gt;Request A writes &lt;code&gt;120 + 300 = 420&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Request B writes &lt;code&gt;120 + 180 = 300&lt;/code&gt; — &lt;strong&gt;overwriting A's write&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The user processed 480 seconds of video but the counter shows 300. They get more usage than they're supposed to. At scale — multiple clips rendering simultaneously — this happens constantly.&lt;/p&gt;

&lt;p&gt;The code &lt;em&gt;looks&lt;/em&gt; correct. It reads, adds, writes. The problem is invisible in single-user testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: push the increment into Postgres
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;increment_daily_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;p_clerk_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;p_seconds&lt;/span&gt;  &lt;span class="nb"&gt;INTEGER&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;daily_usage_seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daily_usage_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p_seconds&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;clerk_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_clerk_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single &lt;code&gt;UPDATE ... SET col = col + n&lt;/code&gt; is atomic. Postgres takes a row-level lock for the duration of the update — no two concurrent updates can interleave. The application becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;increment_daily_usage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;p_clerk_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clerkUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;p_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One network round-trip instead of two, and no race condition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;SECURITY DEFINER&lt;/code&gt;?&lt;/strong&gt; It lets the function run with the privileges of its creator (the DB owner), bypassing row-level security for this specific operation. Since the app calls it via a service-role client that already bypasses RLS, this is consistent — but it's worth knowing the tradeoff.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 2: The webhook handler that processed events twice
&lt;/h2&gt;

&lt;p&gt;Payment providers retry webhook deliveries when they don't receive a &lt;code&gt;200&lt;/code&gt; response quickly enough — network blip, slow cold start, anything. Our handler wasn't idempotent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ... verify signature ...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clerkUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subscriptionId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subscription.activated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// This runs twice if the webhook is retried&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;activateSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clerkUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subscriptionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the provider retried after a slow response, the user's subscription could be activated twice — not catastrophic here, but the pattern generalises to things like crediting accounts or sending emails.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: a deduplication table
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;webhook_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every webhook payload has a unique event ID. We try to insert it; a primary key conflict means we've already processed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isDuplicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webhook_events&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// 23505 = unique_violation in Postgres&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;23505&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isDuplicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawPayload&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// 200 so provider stops retrying&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... process event ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Return &lt;code&gt;200&lt;/code&gt; on duplicate — returning an error would cause the provider to retry &lt;em&gt;again&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;The insert-and-check-conflict is itself atomic — no TOCTOU gap&lt;/li&gt;
&lt;li&gt;The table doubles as an audit log of every event ever received&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Shipping both as one migration
&lt;/h3&gt;

&lt;p&gt;Both fixes are database objects, so they went into a single migration file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- 016_billing_fixes.sql&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;increment_daily_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_clerk_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_seconds&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;daily_usage_seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daily_usage_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p_seconds&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;clerk_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_clerk_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;webhook_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They land together or not at all. Code that depends on both objects was deployed in the same release.&lt;/p&gt;




&lt;h2&gt;
  
  
  The pattern to remember
&lt;/h2&gt;

&lt;p&gt;Both bugs share the same root cause: &lt;strong&gt;state mutation across multiple round-trips is never safe under concurrency&lt;/strong&gt;. The fix in both cases was the same: push the mutation into the database where it can be made atomic.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;UPDATE ... SET col = col + n&lt;/code&gt; instead of read-then-write&lt;/li&gt;
&lt;li&gt;Use an insert-with-conflict as an atomic check-and-record for idempotency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Postgres is very good at this. Let it do the work.&lt;/p&gt;




&lt;p&gt;We're building &lt;a href="https://getclipcrafter.com" rel="noopener noreferrer"&gt;ClipCrafter&lt;/a&gt; — AI-powered short clip extraction from long videos. If you're working on similar problems, I'd love to hear how you handle billing correctness at scale.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>saas</category>
    </item>
    <item>
      <title>Fixing "No Credentials Found" when using AWS SSO Profiles in Zsh</title>
      <dc:creator>nareshipme</dc:creator>
      <pubDate>Mon, 27 Apr 2026 05:50:42 +0000</pubDate>
      <link>https://forem.com/nareshipme/fixing-no-credentials-found-when-using-aws-sso-profiles-in-zsh-3ia7</link>
      <guid>https://forem.com/nareshipme/fixing-no-credentials-found-when-using-aws-sso-profiles-in-zsh-3ia7</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; An AWS SSO login was successful, but subsequent AWS commands failed with "No credentials found" because the shell alias was pointing to a profile name that didn't match the configured credential block in &lt;code&gt;~/.aws/config&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I wanted to automate my AWS authentication by creating an alias in my &lt;code&gt;.zshrc&lt;/code&gt; to trigger the SSO login process. After running the command, the terminal reported &lt;code&gt;Login successful&lt;/code&gt;, but any subsequent attempt to list S3 buckets or use the AWS CLI resulted in a credential error.&lt;/p&gt;

&lt;p&gt;The issue stemmed from a mismatch between how the profile was defined in &lt;code&gt;~/.aws/config&lt;/code&gt; and how I was calling it via the CLI. &lt;/p&gt;

&lt;p&gt;Here is the configuration block I had in my &lt;code&gt;~/.aws/config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[profile 123456789012_AWSEngineerAccessRole]&lt;/span&gt;
&lt;span class="py"&gt;sso_start_url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;https://your-org.awsapps.com/start&lt;/span&gt;
&lt;span class="py"&gt;sso_region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;eu-west-2&lt;/span&gt;
&lt;span class="py"&gt;sso_account_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;123456789012&lt;/span&gt;
&lt;span class="py"&gt;sso_role_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;AWSEngineerAccessRole&lt;/span&gt;
&lt;span class="py"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;eu-west-2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I ran my login command, the output was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Login successful
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However, running a basic AWS command immediately after produced this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unable to locate credentials. You can configure credentials manually via the 'aws configure' command.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure happened because even though the SSO session was active in the background, the AWS CLI does not automatically know which profile to use for subsequent commands unless explicitly instructed. If my alias or environment variable was pointing to a generic &lt;code&gt;default&lt;/code&gt; profile, the CLI looked for credentials under &lt;code&gt;[default]&lt;/code&gt;, found nothing, and failed—ignoring the fact that a valid SSO session existed for the specific &lt;code&gt;123456789012_AWSEngineerAccessRole&lt;/code&gt; profile.&lt;/p&gt;

&lt;p&gt;To fix this, I updated my &lt;code&gt;.zshrc&lt;/code&gt; alias to ensure every command explicitly references the correct profile name defined in the config file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Before (failed because it defaulted to 'default' profile)&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;awslogin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"aws sso login"&lt;/span&gt;

&lt;span class="c"&gt;# After (explicitly targets the SSO profile)&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;awslogin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"aws sso login --profile 123456789012_AWSEngineerAccessRole"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;aws&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'aws --profile 123456789012_AWSEngineerAccessRole'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By appending &lt;code&gt;--profile 123456789012_AWSEngineerAccessRole&lt;/code&gt; to the login command, the CLI maps the successful authentication token specifically to that profile block. By aliasing &lt;code&gt;aws&lt;/code&gt; itself, I ensure all subsequent commands bypass the empty &lt;code&gt;[default]&lt;/code&gt; profile and use the authenticated SSO session.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>zsh</category>
      <category>devops</category>
      <category>cli</category>
    </item>
    <item>
      <title>Why We Switched to Streaming Frame Extraction for Mobile Video Editing</title>
      <dc:creator>nareshipme</dc:creator>
      <pubDate>Fri, 24 Apr 2026 15:32:19 +0000</pubDate>
      <link>https://forem.com/nareshipme/why-we-switched-to-streaming-frame-extraction-for-mobile-video-editing-3cea</link>
      <guid>https://forem.com/nareshipme/why-we-switched-to-streaming-frame-extraction-for-mobile-video-editing-3cea</guid>
      <description>&lt;p&gt;When we first started building ClipCrafter's browser-based video engine, our biggest enemy wasn’t the complexity of FFmpeg—it was something much more silent and deadly: &lt;strong&gt;Memory Exhaustion.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you are working with high-resolution clips or long durations in a Web Worker, there is an invisible wall you will eventually hit. We call it "The OOM (Out Of Render) Wall." &lt;/p&gt;

&lt;p&gt;Recently, we noticed that while our desktop users were enjoying smooth multi-clip stitching, mobile Chrome and Safari users on mid-range Androids/iPhones were experiencing frequent tab crashes during the rendering process. The culprit? Our frame extraction logic was attempting to hold too much in memory at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem: $O(n)$ Memory Complexity
&lt;/h3&gt;

&lt;p&gt;In our early architecture of &lt;code&gt;framewebworker&lt;/code&gt;, we used a pattern that looked like this conceptually (simplified for clarity):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ THE OLD WAY: High risk of OOM crashes on mobile&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractFramesAllAtOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// This array grows linearly with video length!&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// We were grabbing a frame, converting it to an ImageBitmap/Blob...&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;captureFrameAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Every single frame stays in RAM until the end&lt;/span&gt;
    &lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; 
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// If you have 500 frames of a long clip? Goodbye memory!&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this "Load-All" approach, our memory complexity was $O(n)$, where $n$ is total number of extracted frames. For an 8MB video file might be fine—but for high-bitrate clips or longer sequences involving multiple stitches, the browser's heap would balloon until it hit its limit and killed the worker thread (and usually our entire app tab).&lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution: Streaming Extraction
&lt;/h3&gt;

&lt;p&gt;To fix this, we upgraded &lt;code&gt;framewebworker&lt;/code&gt; to version 0.4.0 with a focus on &lt;strong&gt;Streaming Frame Extraction&lt;/strong&gt;. Instead of accumulating an array of frames in memory before starting the stitch process, we moved toward $O(1)$ spatial complexity relative to total frame count (per step).&lt;/p&gt;

&lt;p&gt;We refactored our pipeline so that each extracted frame is processed through its next stage immediately—whether it's being passed into a WebCodecs encoder or written as part of an intermediate stream. &lt;/p&gt;

&lt;p&gt;Here’s how we restructured the logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ THE NEW WAY: Streaming approach (O(1) memory footprint per step)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processFramesStreaming&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onFrameReady&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Callback&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Blob&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;interval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// target fps&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Extract ONLY the current frame needed for this specific timestamp&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;singleFrameBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;captureSingleFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="cm"&gt;/**
     * Instead of pushing to a massive global array 'frames', 
     * we emit it immediately. The downstream consumer (WebCodecs) 
     * processes the frame and then allows this buffer to be garbage collected.
     */&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;onFrameReady&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;singleFrameBuffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why This Matters for Developers Building Heavy Web Apps
&lt;/h3&gt;

&lt;p&gt;By moving from an "Accumulate-then-Process" model to a &lt;strong&gt;Streaming&lt;/strong&gt; model, we achieved two critical wins:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Memory Stability:&lt;/strong&gt; The memory footprint of our worker is now tied more closely to the size of &lt;em&gt;one single frame&lt;/em&gt; rather than total video duration/frame count. This makes mobile rendering significantly more reliable even on low-end devices with restricted heap sizes.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Time To First Byte (TTFB) for Rendering:&lt;/strong&gt; Because we start processing frames as soon as they are extracted, our downstream encoders can begin working immediately without waiting for the "extraction phase" to finish entirely.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Takeaway
&lt;/h3&gt;

&lt;p&gt;When building resource-intensive features in JavaScript—whether it’s video editing with FFmpeg/WebCodecs or heavy data visualization—always ask yourself: &lt;strong&gt;Is my memory usage $O(n)$?&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;If your app's stability degrades as the input size increases, you don't have a logic bug; you have an architectural bottleneck. Switching to streaming-based processing is often more difficult than simple array manipulation (due to managing backpressure and state), but it’s what makes "pro" web tools possible on mobile browsers.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ClipCrafter continues to evolve! Check out our latest updates for even faster WebCodecs hardware acceleration.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>typescript</category>
      <category>performance</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Fixing "No Credentials Found" when using AWS SSO Profiles in Zsh</title>
      <dc:creator>nareshipme</dc:creator>
      <pubDate>Fri, 24 Apr 2026 06:31:24 +0000</pubDate>
      <link>https://forem.com/nareshipme/fixing-no-credentials-found-when-using-aws-sso-profiles-in-zsh-3ogp</link>
      <guid>https://forem.com/nareshipme/fixing-no-credentials-found-when-using-aws-sso-profiles-in-zsh-3ogp</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; An AWS CLI alias was failing with "No credentials found" because the shell command was not explicitly pointing to the specific named profile configured for SSO. I fixed this by appending &lt;code&gt;--profile&lt;/code&gt; to the command execution within the Zsh alias.&lt;/p&gt;

&lt;p&gt;I set up a new Zsh alias to automate my AWS SSO login process and update my local &lt;code&gt;~/.aws/credentials&lt;/code&gt; file. The configuration in my &lt;code&gt;~/ .aws/config&lt;/code&gt; looked correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[profile 123456789012_AWSEngineerAccessRole]&lt;/span&gt;
&lt;span class="py"&gt;sso_start_url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;https://your-org.awsapps.com/start&lt;/span&gt;
&lt;span class="py"&gt;sso_region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;eu-west-2&lt;/span&gt;
&lt;span class="py"&gt;sso_account_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;123456789012&lt;/span&gt;
&lt;span class="py"&gt;sso_role_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;AWSEngineerAccessRole&lt;/span&gt;
&lt;span class="py"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;eu-west-2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However, when running my custom login alias, the CLI returned this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: No credentials found in the configured profile.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issue was that &lt;code&gt;aws sso login&lt;/code&gt; does not automatically assume you want to use a specific named profile if it isn't the &lt;code&gt;[default]&lt;/code&gt; profile. If your configuration uses a custom profile name like &lt;code&gt;123456789012_AWSEngineerAccessRole&lt;/code&gt;, running a bare &lt;code&gt;aws sso login&lt;/code&gt; command causes the CLI to look for credentials under the &lt;code&gt;[default]&lt;/code&gt; block. Since that block was empty or missing the SSO metadata, it failed immediately.&lt;/p&gt;

&lt;p&gt;To fix this, I updated my &lt;code&gt;.zshrc&lt;/code&gt; alias to explicitly reference the profile name using the &lt;code&gt;--profile&lt;/code&gt; flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Before (Failing)&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;awslogin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"aws sso login"&lt;/span&gt;

&lt;span class="c"&gt;# After (Working)&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;awslogin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"aws sso login --profile 123456789012_AWSEngineerAccessRole"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By adding &lt;code&gt;--profile&lt;/code&gt;, the AWS CLI knows exactly which block in &lt;code&gt;~/.aws/config&lt;/code&gt; to parse for the &lt;code&gt;sso_start_url&lt;/code&gt; and &lt;code&gt;sso_account_id&lt;/code&gt;. Now, running &lt;code&gt;awslogin&lt;/code&gt; triggers the browser authentication flow and correctly maps the session tokens to that specific profile.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cli</category>
      <category>zsh</category>
      <category>devops</category>
    </item>
    <item>
      <title>The domain name was just productive procrastination</title>
      <dc:creator>nareshipme</dc:creator>
      <pubDate>Thu, 23 Apr 2026 15:54:37 +0000</pubDate>
      <link>https://forem.com/nareshipme/the-domain-name-was-just-productive-procrastination-16j2</link>
      <guid>https://forem.com/nareshipme/the-domain-name-was-just-productive-procrastination-16j2</guid>
      <description>&lt;p&gt;I finally reached a point with ClipCrafter where I had nothing left to hide behind in the code. &lt;/p&gt;

&lt;p&gt;The features were all there—authentication flows working perfectly, transcription logic solid, clip generation functional, and even an export system that didn't crash every five minutes. After about 238 pull requests, it wasn’t just a "cool experiment" anymore; it was actually running like a real product. But despite having the tech ready to go, I kept delaying one specific thing: getting a domain name.&lt;/p&gt;

&lt;p&gt;There is something deeply uncomfortable about attaching your own URL to an unfinished idea. As long as everything lived on some random deployment sub-domain or localhost, any bugs were just "developer quirks." Once you buy &lt;code&gt;something.com&lt;/code&gt;, the project becomes real. It’s no longer yours alone; it belongs to whoever finds that link in a Google search and decides whether they like what they see.&lt;/p&gt;

&lt;p&gt;When I finally forced myself to face this hurdle last week, I hit an immediate wall of frustration. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;clipcrafter.com? Taken.&lt;/li&gt;
&lt;li&gt;clipcrafter.app? Taken.&lt;/li&gt;
&lt;li&gt;clipcrafter.io? Taken.&lt;/li&gt;
&lt;li&gt;clipcrafter.video? Also taken.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I even had a brief moment where I considered just using my personal brand domain, &lt;code&gt;kshan.ai&lt;/code&gt;. It felt safe because it was already mine and nothing could change with me personally failing at this project—but that would have been dishonest to the product itself. ClipCrafter needed its own identity. &lt;/p&gt;

&lt;p&gt;Instead of spiraling into a naming crisis, I decided to stop manual searching and wrote a quick script to check availability for different prefixes across various TLDs programmatically. It was much less emotional than clicking through domain registrars one by one. That’s when I found &lt;code&gt;getclipcrafter.com&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;It wasn't "perfect," but it followed the classic indie hacker playbook—the same pattern used by giants like Postman (&lt;code&gt;getpostman.com&lt;/code&gt;) or Figma (before they moved to their primary). It was cheap, available, and descriptive enough that people would know exactly what I do. Within minutes of buying it, my DNS records were updated and pointed at a live production deployment.&lt;/p&gt;

&lt;p&gt;Looking back on the last few days spent obsessing over TLDs ($com vs $io) or prefix choices (get- vs try-) was actually quite revealing to me. The domain research wasn't about branding; it was productive procrastination. I used "finding the perfect name" as a shield against launching because once that URL is live, you can no longer hide from feedback.&lt;/p&gt;

&lt;p&gt;The fear of someone using the tool and finding it broken or useless became much more concrete the moment &lt;code&gt;getclipcrafter.com&lt;/code&gt; appeared in my browser bar. But strangely enough, making that fear real made it easier to face. You can't fix a product if nobody is looking at you actually ship it.&lt;/p&gt;

&lt;p&gt;The app is officially live now. No more excuses—time to find those first users and see what they think of the mess I’ve built.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>entrepreneurship</category>
      <category>buildinginpublic</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>When Turbopack Breaks Your Web Worker Builds</title>
      <dc:creator>nareshipme</dc:creator>
      <pubDate>Thu, 23 Apr 2026 15:32:10 +0000</pubDate>
      <link>https://forem.com/nareshipme/when-turbopack-breaks-your-web-worker-builds-1ahe</link>
      <guid>https://forem.com/nareshipme/when-turbopack-breaks-your-web-worker-builds-1ahe</guid>
      <description>&lt;p&gt;In the world of modern frontend development, "speed" is usually a good thing. We want faster hot reloads during development and lightning-fast build times for production. &lt;/p&gt;

&lt;p&gt;Recently at ClipCrafter, we hit this wall head-on when upgrading to Next.js 16. While moving towards Turbopack promised us incredible developer experience improvements, it introduced a silent killer: our video processing engine stopped working in production builds because of how Webpack handles worker files differently than Turbo/Turbopack does during the build pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem: A Broken Worker Pipeline
&lt;/h3&gt;

&lt;p&gt;ClipCrafter relies heavily on &lt;code&gt;@ffmpeg/ffmpeg&lt;/code&gt; to handle heavy-duty video manipulation directly in the browser. This requires loading a specific &lt;code&gt;.wasm&lt;/code&gt; file and an ESM worker via a Blob URL at runtime. &lt;/p&gt;

&lt;p&gt;To make this work, we had configured a custom Webpack loader in &lt;code&gt;next.config.ts&lt;/code&gt;. We needed specifically told webpack how to treat these files so they wouldn't be mangled during the bundling process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.ts (The "Old" Way that worked)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;webpack&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isServer&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isServer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// We needed this custom loader to ensure ffmpeg-core &lt;/span&gt;
      &lt;span class="c1"&gt;// could be resolved correctly via Blob URLs at runtime.&lt;/span&gt;
      &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/ffmpeg&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;dist&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;esm&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;worker&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;js$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;url-loader&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Or specialized worker loaders&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When we upgraded, Next.js started defaulting to Turbopack for builds in certain environments. Since our configuration was strictly targeting the &lt;code&gt;webpack&lt;/code&gt; object and didn't provide a corresponding Turbo-compatible instruction set via &lt;code&gt;turbo: { rules: ... }&lt;/code&gt;, those critical worker files were being bundled incorrectly. The result? A production build that looked perfect but crashed with an "Uncaught TypeError" as soon as you tried to initialize any video clip processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Moving Toward Hardware Acceleration
&lt;/h3&gt;

&lt;p&gt;While fixing the bundler, we also had a chance to look at our performance bottleneck. We realized &lt;code&gt;ffmpeg.wasm&lt;/code&gt; was great for compatibility, but it's essentially running software-based encoding in JavaScript—which is slow and heavy on CPU usage. &lt;/p&gt;

&lt;p&gt;We decided to upgrade/integrate with &lt;code&gt;@framewebworker@0.3.0&lt;/code&gt;. This library uses &lt;strong&gt;WebCodecs&lt;/strong&gt;, a modern browser API that allows us to tap into the device’s hardware acceleration (H.264). The difference was night and day: we saw encoding speeds jump from "painfully slow" up to 10–50× faster on supported browsers, with an automatic fallback to our existing FFmpeg setup for older devices.&lt;/p&gt;

&lt;h3&gt;
  
  
  How We Fixed It
&lt;/h3&gt;

&lt;p&gt;To get the build pipeline stable again while embracing these upgrades, we had two main tasks in a single PR:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Force Webpack for Production:&lt;/strong&gt; Until Next.js provides more robust Turbopack configuration hooks specifically for complex WASM/Worker loaders like ours, we explicitly forced &lt;code&gt;webpack&lt;/code&gt; during our production builds to ensure the &lt;code&gt;@ffmpeg/ffmpeg&lt;/code&gt; worker remains intact and resolvable at runtime. &lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clean up Route Exports:&lt;/strong&gt; We also had a minor type-safety issue where arbitrary constants (like language lists) were being exported from Next.js App Router files, causing TypeScript violations in generated types during build time. In &lt;code&gt;route.ts&lt;/code&gt;, you should &lt;em&gt;only&lt;/em&gt; export HTTP handlers (&lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;POST&lt;/code&gt;) and config objects.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Lesson Learned
&lt;/h3&gt;

&lt;p&gt;If your application relies on "heavy" browser APIs like WebAssembly (WASM), SharedArrayBuffer, or complex Worker threads: &lt;strong&gt;Don't assume the new default bundler just works.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;The transition from Webpack to Turbopack is a massive leap forward for dev speed, but it requires you to audit how your most critical pieces of infrastructure—the ones that don't follow standard "UI component" rules—are being bundled. We fixed our build by reverting the production engine back to its proven Webpack configuration while keeping all other Next.js 16 benefits intact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Have you run into bundling issues with newer versions of Next.js? Let us know in the comments!&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webassembly</category>
      <category>websockets</category>
    </item>
    <item>
      <title>Debugging "No Credentials Found" when Aliasing AWS SSO Login in ZSH</title>
      <dc:creator>nareshipme</dc:creator>
      <pubDate>Thu, 23 Apr 2026 12:13:53 +0000</pubDate>
      <link>https://forem.com/nareshipme/debugging-the-ghost-credentials-when-your-cli-says-one-thing-and-your-config-says-another-4kf4</link>
      <guid>https://forem.com/nareshipme/debugging-the-ghost-credentials-when-your-cli-says-one-thing-and-your-config-says-another-4kf4</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Creating a ZSH alias that runs &lt;code&gt;aws sso login&lt;/code&gt; appeared to succeed, but subsequent commands failed with "no credentials found" because the alias was not correctly setting the profile for downstream tools.&lt;/p&gt;

&lt;p&gt;I was trying to streamline my workflow by adding an alias to my &lt;code&gt;.zshrc&lt;/code&gt; to automate logging into a specific AWS SSO profile. The goal was simple: run one command, and have all subsequent AWS CLI commands work immediately using that authenticated session.&lt;/p&gt;

&lt;p&gt;The alias looked like this in my &lt;code&gt;.zshrc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;awslogin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"aws sso login --profile 123456789012_AWSEngineerAccessRole"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I ran &lt;code&gt;awslogin&lt;/code&gt;, the terminal returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Login successful
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However, as soon as I tried to list my S3 buckets using that same profile, the CLI threw a credential error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fatal error: An error occurred (NoCredentialsError) when calling the ListBuckets operation: unable to locate credentials.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Root Cause
&lt;/h3&gt;

&lt;p&gt;The issue was not with the SSO login itself — the browser-based authentication was completing successfully and updating the token cache in &lt;code&gt;~/.aws/sso/cache&lt;/code&gt;. The problem was a mismatch between how I was initiating the session and how my AWS configuration was structured.&lt;/p&gt;

&lt;p&gt;In my &lt;code&gt;~/.aws/config&lt;/code&gt;, I had defined the profile like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[profile 123456789012_AWSEngineerAccessRole]&lt;/span&gt;
&lt;span class="py"&gt;sso_start_url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;https://your-org.awsapps.com/start&lt;/span&gt;
&lt;span class="py"&gt;sso_region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;eu-west-2&lt;/span&gt;
&lt;span class="py"&gt;sso_account_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;123456789012&lt;/span&gt;
&lt;span class="py"&gt;sso_role_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;AWSEngineerAccessRole&lt;/span&gt;
&lt;span class="py"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;eu-west-2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While &lt;code&gt;aws sso login --profile &amp;lt;name&amp;gt;&lt;/code&gt; successfully refreshed the SSO token, subsequent commands were failing because they were not explicitly told to use that specific profile. The AWS CLI defaults to looking for a &lt;code&gt;[default]&lt;/code&gt; profile or &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; environment variables. Since neither was set, it found nothing — even though the SSO token was valid.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;Update the alias to also export &lt;code&gt;AWS_PROFILE&lt;/code&gt; after a successful login:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;awslogin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'aws sso login --profile 123456789012_AWSEngineerAccessRole &amp;amp;&amp;amp; export AWS_PROFILE=123456789012_AWSEngineerAccessRole'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; means the export only runs if the login succeeded. From that point, every subsequent command in the shell session picks up the correct profile automatically — no &lt;code&gt;--profile&lt;/code&gt; flag needed.&lt;/p&gt;

&lt;p&gt;Verification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;awslogin
Login successful
&lt;span class="nv"&gt;$ &lt;/span&gt;aws s3 &lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;your buckets appear]
&lt;span class="nv"&gt;$ &lt;/span&gt;aws sts get-caller-identity
&lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"UserId"&lt;/span&gt;: &lt;span class="s2"&gt;"..."&lt;/span&gt;,
    &lt;span class="s2"&gt;"Account"&lt;/span&gt;: &lt;span class="s2"&gt;"123456789012"&lt;/span&gt;,
    &lt;span class="s2"&gt;"Arn"&lt;/span&gt;: &lt;span class="s2"&gt;"arn:aws:sts::123456789012:assumed-role/AWSEngineerAccessRole/..."&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;sts get-caller-identity&lt;/code&gt; check is worth adding to your alias or running manually after login — it confirms the session is actually active, not just that the token handshake completed.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>zsh</category>
      <category>devops</category>
      <category>cli</category>
    </item>
    <item>
      <title>Why We Switched from Stripe to Razorpay for ClipCrafter’s Billing Engine</title>
      <dc:creator>nareshipme</dc:creator>
      <pubDate>Wed, 22 Apr 2026 14:01:20 +0000</pubDate>
      <link>https://forem.com/nareshipme/why-we-switched-from-stripe-to-razorpay-for-clipcrafters-billing-engine-331o</link>
      <guid>https://forem.com/nareshipme/why-we-switched-from-stripe-to-razorpay-for-clipcrafters-billing-engine-331o</guid>
      <description>&lt;p&gt;Building a SaaS is rarely just about the core feature. For us at ClipCrafter—an AI-powered video editor—the "video" part was actually one of our easier challenges. The real complexity crept in when we had to figure out how to charge people for it.&lt;/p&gt;

&lt;p&gt;Recently, we hit a major milestone: moving from an experimental Stripe setup into a production-ready billing system powered by Razorpay. While many developers default to Stripe because of its incredible documentation and global footprint, scaling ClipCrafter required us to rethink our payment architecture based on specific regional needs and usage enforcement.&lt;/p&gt;

&lt;h3&gt;
  
  
  The "Why" Behind the Pivot
&lt;/h3&gt;

&lt;p&gt;When we first started prototyping Phase 10 (our Billing &amp;amp; Payments phase), a dual-provider approach seemed attractive for international coverage via Stripe + Razorpay. However, as development progressed, it became clear that maintaining two separate webhooks, two different subscription lifec/ycles, and twofold error handling was creating massive technical debt in our Inngest workflows.&lt;/p&gt;

&lt;p&gt;We decided to simplify: &lt;strong&gt;Razorpay only.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;By focusing on a single provider for the initial launch phase (specifically targeting India-based payments), we were able to prune significant amounts of boilerplate code from both our Next.js API routes and our background workers. We removed &lt;code&gt;stripe_subscription_id&lt;/code&gt; entirely, cleaned up our database migrations in Supabase, and focused strictly on usage enforcement via a unified schema.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementing Usage Enforcement
&lt;/h3&gt;

&lt;p&gt;The real challenge wasn't just "taking money"—it was ensuring that if someone is on the 'Starter' plan, they don’t accidentally trigger an unlimited number of high-compute video renders using our WebWorker architecture. &lt;/p&gt;

&lt;p&gt;We needed a way to check usage limits before every single rendering job hit our worker queue via Inngest. Here is how we structured that logic in TypeScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/billing.ts - Simplified Usage Check Logic&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;UserBillingRow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;planKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;starter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;videoCreditsRemaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateRenderPermissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;supabaseClient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Fetch the current user billing status from Supabase&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;billingData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabaseClient&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user_billing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;planKey, videoCreditsRemaining&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;billingData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Could not retrieve billing information.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;planKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;videoCreditsRemaining&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;billingData&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;UserBillingRow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Enforce hard limits based on the Plan Key&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Checking permissions for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;planKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; user...`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoCreditsRemaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No credits remaining! Please upgrade your plan.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Permission granted to proceed with rendering task&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Lesson: Pruning is as important as Adding
&lt;/h3&gt;

&lt;p&gt;The most satisfying part of this refactor wasn't adding the Razorpay integration—it was deleting everything related to Stripe. We removed entire client libraries, deleted webhook listeners that were no longer being triggered, and cleaned up our &lt;code&gt;PlanBadge&lt;/code&gt; components which previously had "zombie" styles for plans we weren't even supporting anymore via a specific provider.&lt;/p&gt;

&lt;p&gt;In software engineering (and especially in early-stage startups), there is an immense temptation to build every possible integration from Day 1 just because you &lt;em&gt;might&lt;/em&gt; need it later. We learned that the complexity of managing two payment gateways far outweighed any potential benefit for our current user base.&lt;/p&gt;

&lt;h3&gt;
  
  
  Takeaway
&lt;/h3&gt;

&lt;p&gt;If you're building a global SaaS, don't let "feature creep" infect your core infrastructure before they even pay their first invoice. Pick one provider, master its webhook lifecycle and usage enforcement patterns (like we did with Inngest + Supabase), and only expand when the market—not your imagination—demands it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What’s your experience with payment gateways? Did you stick to Stripe or find a more localized solution for much of less friction? Let us know in the comments!&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>nextjs</category>
      <category>razorpay</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Dev Journal: 2026-04-21</title>
      <dc:creator>nareshipme</dc:creator>
      <pubDate>Tue, 21 Apr 2026 06:42:21 +0000</pubDate>
      <link>https://forem.com/nareshipme/dev-journal-2026-04-21-1iei</link>
      <guid>https://forem.com/nareshipme/dev-journal-2026-04-21-1iei</guid>
      <description>&lt;p&gt;/Users/ONBAdmin/Development/term-sheet/scripts/daily-blog.sh: line 132: /users/ONBAdmin/.nvm/versions/node/v20.19.0/bin/claude: No such file or directory&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Dev Journal: 2026-04-21</title>
      <dc:creator>nareshipme</dc:creator>
      <pubDate>Tue, 21 Apr 2026 06:30:08 +0000</pubDate>
      <link>https://forem.com/nareshipme/dev-journal-2026-04-21-4ph</link>
      <guid>https://forem.com/nareshipme/dev-journal-2026-04-21-4ph</guid>
      <description>&lt;p&gt;Not logged in · Please run /login&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
