<?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>DEV Community: yanhua wang</title>
    <description>The latest articles on DEV Community by yanhua wang (@yanhua_wang_4d0f3bfb6f246).</description>
    <link>https://dev.to/yanhua_wang_4d0f3bfb6f246</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3410465%2Fe04c1570-5da2-4dd5-b808-e013cac01e4e.png</url>
      <title>DEV Community: yanhua wang</title>
      <link>https://dev.to/yanhua_wang_4d0f3bfb6f246</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yanhua_wang_4d0f3bfb6f246"/>
    <language>en</language>
    <item>
      <title>Migrating a live SaaS from Vercel + Supabase to a single Cloudflare Worker</title>
      <dc:creator>yanhua wang</dc:creator>
      <pubDate>Tue, 11 Aug 2026 04:12:55 +0000</pubDate>
      <link>https://dev.to/yanhua_wang_4d0f3bfb6f246/migrating-a-live-saas-from-vercel-supabase-to-a-single-cloudflare-worker-3fed</link>
      <guid>https://dev.to/yanhua_wang_4d0f3bfb6f246/migrating-a-live-saas-from-vercel-supabase-to-a-single-cloudflare-worker-3fed</guid>
      <description>&lt;p&gt;I run &lt;a href="https://reddtrends.com" rel="noopener noreferrer"&gt;ReddTrends&lt;/a&gt;. It reads Reddit every week and scores what founders are complaining about into niche opportunities, with GO / WATCH / AVOID verdicts. A few hundred users, a weekly AI pipeline, a daily email job.&lt;/p&gt;

&lt;p&gt;Last Tuesday I moved it off Next.js on Vercel + Supabase and onto one Cloudflare Worker. Same domain, Creem subscriptions live the whole time.&lt;/p&gt;

&lt;p&gt;Four things were worth writing down: the password hashes, the D1 import, the weekly pipeline, and one payment row I deleted by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, before and after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App&lt;/td&gt;
&lt;td&gt;Next.js on Vercel&lt;/td&gt;
&lt;td&gt;TanStack Start + React 19 on Cloudflare Workers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB&lt;/td&gt;
&lt;td&gt;Supabase (Postgres)&lt;/td&gt;
&lt;td&gt;Cloudflare D1 (SQLite)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;Supabase Auth (GoTrue)&lt;/td&gt;
&lt;td&gt;Better Auth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly AI pipeline&lt;/td&gt;
&lt;td&gt;GitHub Actions cron → one long HTTP endpoint&lt;/td&gt;
&lt;td&gt;Worker Cron Trigger → Cloudflare Workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily emails&lt;/td&gt;
&lt;td&gt;GitHub Actions cron&lt;/td&gt;
&lt;td&gt;Worker Cron Trigger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inbound mail&lt;/td&gt;
&lt;td&gt;Zoho&lt;/td&gt;
&lt;td&gt;Cloudflare Email Routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbound mail&lt;/td&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;td&gt;Resend (unchanged)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files / cache&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;R2 + Workers KV&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Row 3 is the one I cared about. The rest came along for the ride.&lt;/p&gt;

&lt;p&gt;I didn't port the Next.js app. OpenNext runs Next.js on Workers and is the lower-risk path if you have a large app to move. Mine was small, and I rebuilt on a TanStack Start boilerplate that already targeted Workers natively, so there was no adapter layer to keep working.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. You don't have to reset everyone's password
&lt;/h2&gt;

&lt;p&gt;Leaving Supabase Auth means inheriting 938 bcrypt hashes from GoTrue. Better Auth hashes with scrypt. I could have forced a password reset on everyone, which is a good way to lose the users who were only half committed.&lt;/p&gt;

&lt;p&gt;You can just override the verify 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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;bcrypt&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;bcryptjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&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;verifyPassword&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;verifyScryptPassword&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;better-auth/crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// GoTrue emits $2a$; bcryptjs emits $2b$; $2y$ is the PHP variant.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BCRYPT_HASH&lt;/span&gt; &lt;span class="o"&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;2&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;aby&lt;/span&gt;&lt;span class="se"&gt;]\$&lt;/span&gt;&lt;span class="sr"&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;verifyPasswordCompat&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&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="nx"&gt;BCRYPT_HASH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hash&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;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hash&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="nf"&gt;verifyScryptPassword&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;betterAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;emailAndPassword&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;verifyPasswordCompat&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// verify only, never hash&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;This works on Workers because bcrypt &lt;code&gt;compare&lt;/code&gt; pulls the salt out of the stored hash, so it needs no RNG and runs fine on workerd. bcrypt &lt;em&gt;hashing&lt;/em&gt; needs a PRNG, and that's where people usually hit the wall. We never call it. New passwords go through the default scrypt hasher, and migrated users verify against their old hash indefinitely.&lt;/p&gt;

&lt;p&gt;It costs 100-250ms of CPU per login for those users. For something that happens once a session I decided I didn't care.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Postgres types don't survive the trip to D1
&lt;/h2&gt;

&lt;p&gt;D1 is SQLite. No booleans, no native timestamp type, no jsonb. I ended up with four scripts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;01-export     Supabase → NDJSON
02-transform  pure, offline, no network
03-import     NDJSON → D1 REST API (idempotent)
04-verify     per-table counts + money sums, old vs new
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the transform, per table I declare which columns are timestamps, date text, JSON or booleans, then coerce:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;            &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toMs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;         &lt;span class="c1"&gt;// Date → epoch millis&lt;/span&gt;
&lt;span class="k"&gt;else&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;dateText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toDateText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 'YYYY-MM-DD'&lt;/span&gt;
&lt;span class="k"&gt;else&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;     &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toJsonText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// jsonb → TEXT&lt;/span&gt;
&lt;span class="k"&gt;else&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;bool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;     &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;boolTo01&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// true → 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two D1 limits I hit while writing the importer. Maximum SQL statement length is 100,000 bytes, and some of my report rows carry large JSON blobs, so inlining them as SQL text blows past it immediately. Use bound parameters. The cap there is 100 bound parameters per query, so I batch at 90.&lt;/p&gt;

&lt;p&gt;The importer does &lt;code&gt;INSERT OR REPLACE&lt;/code&gt; by primary key, so it's idempotent. On cutover day I ran a final delta sync while the old site was still taking writes, then ran it twice more because I didn't trust it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part where I couldn't reach Supabase at all
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;db.&amp;lt;ref&amp;gt;.supabase.co&lt;/code&gt; is IPv6 only. I'm behind Clash with fake-IP DNS, so raw TCP to 5432 just hung, with nothing in the error worth reading.&lt;/p&gt;

&lt;p&gt;The fix is the IPv4 session pooler host, &lt;code&gt;aws-0-&amp;lt;region&amp;gt;.pooler.supabase.com&lt;/code&gt;, with the &lt;code&gt;postgres.&amp;lt;project-ref&amp;gt;&lt;/code&gt; username form and &lt;code&gt;sslmode=no-verify&lt;/code&gt;. That last flag is for a one-off export script on my own machine, not something to carry into an app connection string. That one cost me an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Workflows, which is why I did this
&lt;/h2&gt;

&lt;p&gt;The weekly pipeline: scrape ~10 subreddits, run an LLM over the posts to pull out niches, score and rank, generate build kits, persist, publish, queue emails. 500+ seconds end to end.&lt;/p&gt;

&lt;p&gt;On the old stack that was one HTTP invocation fired by a GitHub Actions schedule, all or nothing. If persist failed I lost the collection and the analysis, including LLM tokens I'd already paid for, and re-running meant paying for them again.&lt;/p&gt;

&lt;p&gt;Cloudflare Workflows persists the result of each &lt;code&gt;step.do()&lt;/code&gt;, so a failure retries from the failed step:&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NicheDiscoveryWorkflow&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;WorkflowEntrypoint&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;run&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;step&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;enabled&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;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;check-enabled&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;isPipelineEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&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;enabled&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="na"&gt;success&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="na"&gt;skipped&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;analyzed&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;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;collect-analyze-score&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;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;30 seconds&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;exponential&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;15 minutes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="k"&gt;async &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="cm"&gt;/* scrape → LLM → score */&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;report&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;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;persist-and-publish&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;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;10 seconds&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;exponential&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;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;persistScoredReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;analyzed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scoredTop10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I got wrong on the first pass.&lt;/p&gt;

&lt;p&gt;I split steps by module: collect, analyze, score, persist, one each. What matters is what's expensive to redo. Collection is KV-cached and nearly free, the LLM call is where the money goes. So collect + analyze + score collapsed into a single step with the boundary right after it, and now a persist failure never re-spends analysis tokens.&lt;/p&gt;

&lt;p&gt;I also passed the raw post array between steps. Step state gets persisted and is size limited, so that falls over as soon as a week's scrape gets big. Steps now hand off the ~10 scored niches and nothing else.&lt;/p&gt;

&lt;p&gt;Config is three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="nl"&gt;"triggers"&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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"crons"&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;"0 23 * * 1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 9 * * *"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 1 * * *"&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="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"workflows"&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"niche-discovery"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NICHE_WORKFLOW"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"class_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;"NicheDiscoveryWorkflow"&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;Plus re-exporting the class from your Worker entry, which I forgot, and then spent ten minutes being confused about. First production run took 7 minutes and came out green. I kept the old synchronous endpoint around behind a &lt;code&gt;CRON_SECRET&lt;/code&gt; bearer token as a manual fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Email turned out to be two problems
&lt;/h2&gt;

&lt;p&gt;Inbound was the easy one. My Zoho plan was expiring anyway, and Cloudflare Email Routing forwards &lt;code&gt;support@&lt;/code&gt; to Gmail for free. Three MX records.&lt;/p&gt;

&lt;p&gt;Outbound was harder. Resend's free tier is 100/day and I have around 900 weekly subscribers, so the job is to drain a backlog on a schedule.&lt;/p&gt;

&lt;p&gt;The daily cron takes at most 90, leaving headroom under the limit, ordered paid users first and then by oldest &lt;code&gt;last_sent_at&lt;/code&gt;, with a log table so nobody gets the same report twice. Whatever doesn't fit rolls into tomorrow's run. A full cycle takes about ten days, which I'm fine with, because the report is weekly and the people paying for it get it on day one.&lt;/p&gt;

&lt;p&gt;Raising the cap later is one secret:&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;echo&lt;/span&gt; &lt;span class="s1"&gt;'1000'&lt;/span&gt; | pnpm wrangler secret put NICHE_EMAIL_DAILY_LIMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Cutover day
&lt;/h2&gt;

&lt;p&gt;Two things went wrong.&lt;/p&gt;

&lt;p&gt;The first was DNS error 100117 on deploy. Attaching the apex as a Worker custom domain fails while the old A record still points at Vercel. Deleting the old records and redeploying fixed it, which was obvious afterwards.&lt;/p&gt;

&lt;p&gt;The second was worse. I'd accumulated test-mode payment rows in the new database during the soft launch, so I wrote a cleanup to drop them. One of the rows I classified as test data was a real pending $95 payment from a real customer.&lt;/p&gt;

&lt;p&gt;I caught it because the verify script compares money sums per table, not only row counts. The totals didn't reconcile, I went digging, and a full re-import from production put the row back. Reconcile sums per table. A row count can match while the money is wrong.&lt;/p&gt;

&lt;p&gt;The rest of the final verify was clean: 938 users, 8,193 email log rows, and every subscription and payment row reconciled on both count and sum. One expected diff, where D1 had two extra environment kill-switch keys that never existed on the old stack.&lt;/p&gt;

&lt;p&gt;The rollback stayed loaded the whole time. DNS points back at Vercel whenever, Supabase stayed up for two weeks, and because the import is idempotent, recovering writes made during a rollback window is just &lt;code&gt;03-import --since&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  On cost
&lt;/h2&gt;

&lt;p&gt;I'm a week in and don't have a number worth publishing, so I'm not going to invent one. What I can say is that the app, database, object storage, cache, three cron triggers, the durable pipeline and inbound email are now one Cloudflare account and one &lt;code&gt;pnpm deploy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The surprise was Workflows. I migrated for cost and consolidation and came away mostly caring about durable execution. Getting retry and backoff as a config object on a step, instead of hand-rolling a queue with at-least-once semantics, changed what I'm willing to put in a cron job at all.&lt;/p&gt;

&lt;p&gt;If you're planning the same move, the Workflows step design and the D1 importer are the two pieces I have the most to say about. Ask and I'll write them up properly.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
