<?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: David Bartalos</title>
    <description>The latest articles on DEV Community by David Bartalos (@dbartalos).</description>
    <link>https://dev.to/dbartalos</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%2F3936644%2F0fa9b784-f271-4f90-b49c-b91333078f03.png</url>
      <title>DEV Community: David Bartalos</title>
      <link>https://dev.to/dbartalos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dbartalos"/>
    <language>en</language>
    <item>
      <title>Replace the Heuristic With a Boundary: Rebuilding a Leave Table as an Append-Only Ledger</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Fri, 14 Aug 2026 10:29:58 +0000</pubDate>
      <link>https://dev.to/dbartalos/replace-the-heuristic-with-a-boundary-rebuilding-a-leave-table-as-an-append-only-ledger-2b9j</link>
      <guid>https://dev.to/dbartalos/replace-the-heuristic-with-a-boundary-rebuilding-a-leave-table-as-an-append-only-ledger-2b9j</guid>
      <description>&lt;p&gt;We deleted the reconciliation step from a data migration and replaced it with a single timestamp. Nothing overlaps, so nothing needs matching.&lt;/p&gt;

&lt;p&gt;That's the ending. Here's how we got there — and the short version of part 1, for anyone arriving without it:&lt;/p&gt;

&lt;p&gt;We were moving short-term leave data from a legacy processor to a new service. To avoid double-counting during the overlap, a step called &lt;strong&gt;heal&lt;/strong&gt; matched a new row against a legacy-seeded one on value shape — &lt;code&gt;(employee_id, date, leave_type, unit, amount)&lt;/code&gt;, the only columns the two feeds shared — and appended a compensating negative entry. Two days of bug fixing later we measured it: &lt;strong&gt;9 of the 13 compensating rows it had ever written in production were wrong&lt;/strong&gt;, and 238 of 244 overlapping employee-days reconciled &lt;em&gt;exactly&lt;/em&gt; with no compensation at all. Separately, the table's primary key &lt;code&gt;(request_id, date)&lt;/code&gt; turned out not to be unique — one correction request can carry four entries across two dates, and our upsert silently published 16 hours where the truth was 8.&lt;/p&gt;

&lt;p&gt;Two problems: rows that shouldn't be matched, and rows that shouldn't collide.&lt;/p&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;Grain&lt;/td&gt;
&lt;td&gt;one row per &lt;code&gt;(request, date)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;one row per &lt;strong&gt;entry&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(request_id, date)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(request_id, date, amount, seq)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conflict&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DO UPDATE … WHERE IS DISTINCT FROM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DO NOTHING&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amount type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;REAL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NUMERIC(10,4)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Overlap handling&lt;/td&gt;
&lt;td&gt;match on value shape, compensate&lt;/td&gt;
&lt;td&gt;a cutover boundary; no matching at all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Start from the read path
&lt;/h2&gt;

&lt;p&gt;The thing we should have looked at first is what the consumer actually asks for:&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leave_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;leave_ledger&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;unnest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;[]))&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leave_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a ledger read. It sums signed rows and never cares how many there are. It had looked like that from day one. Every problem in part 1 came from the write path underneath it being a mutable table pretending otherwise — and from heal, which existed only to keep that mutable table's arithmetic straight.&lt;/p&gt;

&lt;p&gt;Make the write path agree with the read path and both problems dissolve.&lt;/p&gt;




&lt;h2&gt;
  
  
  One row per entry, keyed on the amount, never updated
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;leave_ledger&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leave_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'new-feed'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three deliberate choices in five lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;DO NOTHING&lt;/code&gt;, not &lt;code&gt;DO UPDATE&lt;/code&gt;.&lt;/strong&gt; The rolling lookback deliberately re-sends the same request across consecutive syncs, and the transformer derives the same key set from the same payload every time, so a re-send is an exact no-op. Rows are never mutated. This isn't purism — part 1's measurement found that 0 of 1,299 rows had ever been updated across syncs anyway. The update branch existed only to produce the 16h/0h bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;amount&lt;/code&gt; is in the key.&lt;/strong&gt; Here is the correction from part 1, stored under the new key:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;entry&lt;/th&gt;
&lt;th&gt;date&lt;/th&gt;
&lt;th&gt;amount&lt;/th&gt;
&lt;th&gt;seq&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;13/08&lt;/td&gt;
&lt;td&gt;&lt;code&gt;−8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;13/08&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;distinct key — differs on &lt;code&gt;amount&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;14/08&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;14/08&lt;/td&gt;
&lt;td&gt;&lt;code&gt;−8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;distinct key — differs on &lt;code&gt;amount&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;balance: 8 and 8&lt;/strong&gt; ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four entries, four rows, nothing overwritten, and the day totals come out right without anyone netting anything by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;RETURNING *&lt;/code&gt; is the change signal.&lt;/strong&gt; It yields rows only for genuinely new entries — precisely the "what changed, what should we republish downstream" question the old &lt;code&gt;IS DISTINCT FROM&lt;/code&gt; predicate was computing. The change-detection logic didn't need porting to the new model; it fell out of the insert. That was the moment the design felt right rather than merely correct.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;code&gt;seq&lt;/code&gt;, and why it counts per group
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;amount&lt;/code&gt; in the key separates &lt;code&gt;−8&lt;/code&gt; from &lt;code&gt;+8&lt;/code&gt;. It does not separate a shift split as &lt;code&gt;13/08 - 4 Hours; 13/08 - 4 Hours&lt;/code&gt;, where the two entries are genuinely identical:&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;// Number entries within each (date, amount) group, not across the whole request.&lt;/span&gt;
&lt;span class="c1"&gt;// Because the entries it separates are identical, any ordering of them is equivalent —&lt;/span&gt;
&lt;span class="c1"&gt;// so re-parsing the same payload in any order yields the same key set.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seqByGroup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&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="kr"&gt;number&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;return&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="o"&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;date&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;amount&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seqByGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;seqByGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seq&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="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;employeeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;leaveType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unit&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;Counting per &lt;code&gt;(date, amount)&lt;/code&gt; group rather than per entry matters more than it looks. The correction payload from part 1 proves the source doesn't order entries consistently — &lt;code&gt;−8, +8&lt;/code&gt; on one date and &lt;code&gt;+8, −8&lt;/code&gt; on the other, in the same request. A &lt;code&gt;seq&lt;/code&gt; assigned by entry position would change between syncs, produce new keys, and re-insert the same absence forever. Assigned per group, it's a pure function of the payload's contents.&lt;/p&gt;

&lt;p&gt;Is it necessary? We can't prove it. A &lt;code&gt;seq &amp;gt; 1&lt;/code&gt; row has never been observed in production. And we can't go and check the history either, because the old upsert overwrote the evidence — the exact rows that would tell us are the ones it destroyed. So &lt;code&gt;seq&lt;/code&gt; ships as a column that is either load-bearing or free, and there is no experiment available that distinguishes those. Given the alternative is finding out after the balances are wrong, that's a fine trade.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;code&gt;NUMERIC&lt;/code&gt;, not &lt;code&gt;REAL&lt;/code&gt;, once it's in the key
&lt;/h2&gt;

&lt;p&gt;The legacy column was &lt;code&gt;REAL&lt;/code&gt; and nobody had minded. Putting the amount in the primary key changes that, because the data is not binary-exact: fractional-day holidays exist, and out of a &lt;code&gt;REAL&lt;/code&gt; column they read back as &lt;code&gt;0.83000004&lt;/code&gt; and &lt;code&gt;0.66999996&lt;/code&gt;, with a booking-plus-correction pair netting to &lt;code&gt;−4.47e-8&lt;/code&gt; instead of &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Keying on a type that cannot represent its own values exactly makes luck load-bearing. &lt;code&gt;NUMERIC(10,4)&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The boundary that replaced heal
&lt;/h2&gt;

&lt;p&gt;The report we poll is filtered on a &lt;code&gt;completed_on_or_after&lt;/code&gt; parameter. Set that boundary to the instant the seed snapshot was taken, and &lt;strong&gt;no request in the seed can ever arrive from the new feed&lt;/strong&gt;. Nothing overlaps, so nothing needs matching. The unanswerable question is never asked.&lt;/p&gt;

&lt;p&gt;That's the entire replacement for heal: a parameter we were already passing, given one specific value.&lt;/p&gt;

&lt;p&gt;The boundary is &lt;strong&gt;completion time, not absence date&lt;/strong&gt; — an easy thing to get wrong. Corrections to historical absences legitimately arrive after cutover; they're just new ledger entries whose signed amount adjusts the balance. Cutting on absence date would drop them on the floor.&lt;/p&gt;

&lt;h3&gt;
  
  
  What that deleted
&lt;/h3&gt;

&lt;p&gt;The whole change was 16 files, &lt;strong&gt;+1,230 / −1,158&lt;/strong&gt;. The interesting part is where the deletions landed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The queries file (heal's five CTEs, the upsert, the change predicate)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+27 / −184&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Its repository tests&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+96 / −506&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everything under &lt;code&gt;src/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+417 / −847&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The test file is the number I'd point at. Five hundred lines of tests didn't get &lt;em&gt;deleted&lt;/em&gt; so much as &lt;em&gt;become meaningless&lt;/em&gt;: they tested ranking, netting, snapshot-bounding and 1:1 capping, none of which are concepts in the new model. All five root causes from part 1 were retired structurally — the code path they lived in no longer exists, so they can't regress rather than merely being fixed. 277 tests pass on the other side.&lt;/p&gt;




&lt;h2&gt;
  
  
  The assumption we couldn't eliminate
&lt;/h2&gt;

&lt;p&gt;Honest ledgers have honest caveats. Ours: because &lt;code&gt;amount&lt;/code&gt; is in the key, a &lt;em&gt;restated&lt;/em&gt; amount under an existing request ID would &lt;strong&gt;append&lt;/strong&gt; a second entry rather than conflict with the first, inflating the balance. &lt;code&gt;DO NOTHING&lt;/code&gt; cannot catch it — a changed value simply isn't a conflict.&lt;/p&gt;

&lt;p&gt;We can't prevent it, so we detect it. The report always sends a request's full entry list, so for any request ID it mentions, the entries we derive should exactly equal what we've stored. A stored entry the report no longer claims means the request was restated rather than corrected:&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;// Warn-only, deliberately. A false positive must never block a sync.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findStoredEntryKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestIds&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;identity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&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="c1"&gt;// For each request the report mentions, compare its derived identity set against&lt;/span&gt;
&lt;span class="c1"&gt;// the stored one; log a warning for any stored entry the report no longer claims.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has never fired. If it ever does, we find out from a log line rather than from payroll.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invariants we run
&lt;/h2&gt;

&lt;p&gt;Three one-line guards, run alongside the parity query. Each maps to a way the model could quietly stop being true:&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'mutated ledger rows'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;invariant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;violations&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;leave_ledger&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'heal-sourced rows'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;leave_ledger&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'heal'&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'legacy rows losing precision at NUMERIC(10,4)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;leave_legacy&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="k"&gt;IS&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;AND&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first says nothing has learned to update a ledger row. The second says nobody has reintroduced compensation — the mechanism is gone, so any row claiming that source is a regression by definition. The third is the one I like: before you migrate &lt;code&gt;REAL&lt;/code&gt; into a narrower &lt;code&gt;NUMERIC&lt;/code&gt;, make the database tell you whether every existing value survives the round trip.&lt;/p&gt;




&lt;h2&gt;
  
  
  The cutover is where the risk actually went
&lt;/h2&gt;

&lt;p&gt;Deleting heal didn't remove risk from the migration, it &lt;em&gt;concentrated&lt;/em&gt; it into one step: deriving the boundary. Here are six consecutive parity runs, with no code changes between them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Mismatched employee-days&lt;/th&gt;
&lt;th&gt;Net hours diff&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Seeded, narrow catchup window&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;235&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;−394.75&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Catchup window widened&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;−41&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Widened again&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;+7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Both feeds caught up&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;One new booking mid-run&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;+8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Both schedulers live&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Run 1 is the lesson.&lt;/strong&gt; We derived the boundary from &lt;code&gt;MAX(created_at)&lt;/code&gt; on the seeded rows while the legacy processor was still writing. But legacy's &lt;em&gt;write&lt;/em&gt; clock lags the platform's &lt;em&gt;completion&lt;/em&gt; clock by up to about 7 hours 15 minutes — a 13-hour approval lookback, a 30-minute cadence, and no runs overnight. Any request that completed before the boundary but was written after it landed in &lt;strong&gt;neither&lt;/strong&gt; side. 235 employee-days, ~395 hours, invisible until we widened the catchup window far enough to sweep them back up.&lt;/p&gt;

&lt;p&gt;The fix is one step at the top of the seed script: &lt;strong&gt;disable and drain the legacy processor before deriving the boundary.&lt;/strong&gt; That makes the gap empty by construction rather than by luck — the difference between a boundary that's sound and one that happens to work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run 5 is the other lesson.&lt;/strong&gt; A single new booking arrived between runs and showed as a mismatch until the other feed's next cycle. A non-zero parity result is not automatically a regression — and "stable across two runs" doesn't rule out lag either. We had a mismatch set hold steady across two consecutive runs and resolve on the third.&lt;/p&gt;

&lt;p&gt;Runs 4 and 6 are both zero with 15+ new rows landing on each side in between, which is what makes it steady-state agreement rather than two feeds happening to line up after a bad snapshot. Both sides: 212,887 rows, 17,808 employees, hours totals identical.&lt;/p&gt;

&lt;p&gt;One caveat we wrote down rather than chased: the days column reads −0.0022 forever, because the legacy column is &lt;code&gt;REAL&lt;/code&gt;, ours is &lt;code&gt;NUMERIC&lt;/code&gt;, and Postgres returns &lt;code&gt;sum(real) -&amp;gt; real&lt;/code&gt;. It's documented as expected, with the &lt;code&gt;&amp;gt; 0.0001&lt;/code&gt; tolerance in the comparison queries. Don't let a known float artifact train your team to ignore a red result.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this actually stands
&lt;/h2&gt;

&lt;p&gt;Because a war story that ends "and then it was perfect" is a war story that's lying to you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The real cutover hasn't happened.&lt;/strong&gt; Everything above is a rehearsal. Parity was reached by &lt;em&gt;convergence&lt;/em&gt; — widening the catchup window until both feeds covered the gap — not by a sound boundary. The production run still has to do the drain-first sequence that run 1 taught us.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing reads the ledger yet.&lt;/strong&gt; The API and the warehouse view still read the legacy table. The parity we're protecting is parity for a table with no consumers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;That's also why the rebuild was affordable.&lt;/strong&gt; Changing a primary key on ~3M rows was a drop-and-recreate rather than a delicate in-place migration, precisely because no reader would notice. Had this been found a phase later, the same fix would have been an order of magnitude more expensive. The bug was worth finding early far more than it was worth fixing well.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What the design taught us
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Prefer a boundary to a heuristic.&lt;/strong&gt; If you need to decide whether two records describe the same real-world event and they share no identifier, don't build a matcher — find a boundary that makes the overlap empty. Ours already existed as a filter parameter on the source report. We'd just never thought of it as a cutover instant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deleting the mechanism moves the risk, it doesn't remove it.&lt;/strong&gt; Heal was over a thousand lines of continuously-wrong compensation; the boundary is one timestamp that has to be right once. Much better trade — but only if you treat that instant with the seriousness those thousand lines used to absorb. Run 1 is what happens when you don't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put the thing you key on in a type that can represent it.&lt;/strong&gt; &lt;code&gt;REAL&lt;/code&gt; was fine as a payload column and unfit as a key column, and nothing about the migration would have told us that if we hadn't gone looking for &lt;code&gt;0.83000004&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Immutability isn't purism when the data is financial.&lt;/strong&gt; Leave balances feed payroll. Corrections staying individually queryable, rather than being netted away by an update, is worth the extra rows — and the source was already emitting signed deltas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let the write path match the read path.&lt;/strong&gt; This is the part that stings. The consumer query was &lt;code&gt;SUM(amount) GROUP BY employee, date, type&lt;/code&gt; all along. It had been a ledger the whole time. We'd just built a mutable table underneath it and spent two days reconciling the difference.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>architecture</category>
      <category>database</category>
      <category>datamigration</category>
    </item>
    <item>
      <title>Nine of Thirteen Corrections Were Wrong: The Fix That Came With a Proof It Couldn't Work</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Fri, 14 Aug 2026 10:29:48 +0000</pubDate>
      <link>https://dev.to/dbartalos/nine-of-thirteen-corrections-were-wrong-the-bug-fix-that-proved-our-design-undecidable-42ca</link>
      <guid>https://dev.to/dbartalos/nine-of-thirteen-corrections-were-wrong-the-bug-fix-that-proved-our-design-undecidable-42ca</guid>
      <description>&lt;p&gt;Nine of the thirteen corrections our migration code had ever written in production were wrong. We found that out by stopping the bug fixes and counting.&lt;/p&gt;

&lt;p&gt;Some context. We were migrating short-term leave data — sick days, compassionate leave, the odd half-day — from a legacy processor to a new integration service. Same source system upstream, same consumers downstream, new table in the middle. The kind of migration you scope at a week.&lt;/p&gt;

&lt;p&gt;Two days of it went into fixing bugs in a mechanism that could never have worked. This is how we finally noticed. Part 2 is what we replaced it with.&lt;/p&gt;

&lt;p&gt;All identifiers are anonymised and the systems described generically. The numbers are real.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Two feeds write leave data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The legacy processor&lt;/strong&gt; — running for years, polls the HR platform every 30 minutes during the day, writes one row per employee-day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The new service&lt;/strong&gt; — polls every 15 minutes, 24/7, writes to a new table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To cut over, we froze a snapshot of the legacy table, copied it into the new table tagged &lt;code&gt;source = 'legacy'&lt;/code&gt;, and let the new feed take over. The new service would gradually re-report the same absences as its rolling lookback window swept over them.&lt;/p&gt;

&lt;p&gt;Which creates the obvious problem: the new feed writes an absence that is already in the table as a legacy row, and the balance double-counts.&lt;/p&gt;

&lt;p&gt;Our answer was a step called &lt;strong&gt;heal&lt;/strong&gt;. When the new feed wrote a row that looked like an existing legacy row, heal appended a compensating negative entry cancelling the legacy one out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;source&lt;/th&gt;
&lt;th&gt;date&lt;/th&gt;
&lt;th&gt;amount&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;legacy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;13/08&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;seeded from the snapshot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;new-feed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;13/08&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;re-reported by the new service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;13/08&lt;/td&gt;
&lt;td&gt;&lt;code&gt;−8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;written by heal to cancel the legacy row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;8&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;what the consumer reads&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The balance stays correct, and over time every legacy row gets superseded by a properly-sourced one. The consumer never sees any of this — it reads &lt;code&gt;SUM(amount)&lt;/code&gt; per employee-day. Remember that detail; it matters more than anything else in this post.&lt;/p&gt;

&lt;p&gt;The catch — and we knew this at design time, we just didn't weigh it properly — is that the two feeds &lt;strong&gt;share no identifier&lt;/strong&gt;. Legacy request IDs are day-level. The new report's are request-level, and one request can span five days. There is no join key.&lt;/p&gt;

&lt;p&gt;So heal matched on the only columns both feeds had:&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;-- "Is this new-feed row a replacement for that legacy row?"&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leave_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Match on value shape. It worked in testing. It worked in the first sync after the snapshot.&lt;/p&gt;




&lt;h2&gt;
  
  
  The parity check
&lt;/h2&gt;

&lt;p&gt;Everything that follows came out of one query. Sum both tables per employee-day, full outer join, show me anything that differs:&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="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;legacy&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;leave_legacy&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;leave_ledger&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&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;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&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;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;legacy_total&lt;/span&gt;&lt;span class="p"&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;ledger_total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;legacy&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;
&lt;span class="k"&gt;FULL&lt;/span&gt; &lt;span class="k"&gt;OUTER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;employee_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;employee_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;ABS&lt;/span&gt;&lt;span class="p"&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&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;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&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;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0001&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to note, because both come back later. The comparison key is &lt;code&gt;(employee_id, date)&lt;/code&gt; — never the request ID, since the two feeds' IDs are at different grains and never match. And that &lt;code&gt;&amp;gt; 0.0001&lt;/code&gt; tolerance is not defensive padding: the legacy column is &lt;code&gt;REAL&lt;/code&gt;, ours is &lt;code&gt;NUMERIC&lt;/code&gt;, and Postgres returns &lt;code&gt;sum(real) -&amp;gt; real&lt;/code&gt;, so an exact comparison reports float noise as drift. &lt;code&gt;0.83&lt;/code&gt; reads back as &lt;code&gt;0.83000004&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That query returns rows. So you go and fix things.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two days of whack-a-mole
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bug 1 — the superseded field.&lt;/strong&gt; The HR platform had quietly started sending a new field for the leave type name, superseding the old one. Both were present during the transition. We were reading the old one. Type labels diverged between the feeds, so heal never matched, so nothing healed, so everything double-counted. Around 1,900 records' worth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 2 — timezone off-by-one.&lt;/strong&gt; &lt;code&gt;toIsoDate()&lt;/code&gt; read a Postgres &lt;code&gt;DATE&lt;/code&gt; back and called &lt;code&gt;.toISOString()&lt;/code&gt; on it. The driver builds that &lt;code&gt;Date&lt;/code&gt; from local-time components, not UTC, so any process running ahead of UTC shifts the calendar day by one. Heal was comparing the 12th against the 13th and finding nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 3 — case sensitivity.&lt;/strong&gt; The two feeds spelled some leave types with different casing. The match was case-sensitive.&lt;/p&gt;

&lt;p&gt;Fixing 2 and 3 together took a test sync from &lt;strong&gt;25 of 45&lt;/strong&gt; rows healing correctly to &lt;strong&gt;472 of 473&lt;/strong&gt;. That felt like winning. We added a regression query to the comparison script and moved on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 4 — the unbounded heal.&lt;/strong&gt; The replacement test was an &lt;code&gt;EXISTS&lt;/code&gt;: does &lt;em&gt;a&lt;/em&gt; new-feed row with this value shape exist for this employee-day? If two legacy rows happened to share an identical shape on the same day and only one replacement existed, &lt;code&gt;EXISTS&lt;/code&gt; was true for both, so heal cancelled both — moving the balance by &lt;code&gt;(legacy_count − replacement_count) × amount&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix was to cap healing 1:1 per group: count the available replacements, count the ones already used by previous heals, rank the unhealed legacy rows, and only heal up to the difference.&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="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="k"&gt;AS&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="n"&gt;legacy_rows&lt;/span&gt; &lt;span class="k"&gt;AS&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="c1"&gt;-- + whether each is already healed&lt;/span&gt;
     &lt;span class="n"&gt;group_used&lt;/span&gt; &lt;span class="k"&gt;AS&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="c1"&gt;-- replacements consumed by previous runs&lt;/span&gt;
     &lt;span class="n"&gt;replacement_counts&lt;/span&gt; &lt;span class="k"&gt;AS&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="c1"&gt;-- replacements available in this group&lt;/span&gt;
     &lt;span class="n"&gt;ranked_unhealed&lt;/span&gt; &lt;span class="k"&gt;AS&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="c1"&gt;-- ROW_NUMBER() over the group&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;leave_ledger&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;SELECT&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ranked_unhealed&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rn&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;available_replacements&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;used_replacements&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked, and it was idempotent, and it turned a twenty-line statement into five CTEs. At the time that read as rigour. It was actually the mechanism telling us something: a matching rule that needs rank-and-cap arithmetic to stay correct is a matching rule that doesn't have enough information to work with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 5 — the one that broke it.&lt;/strong&gt; A brand-new leave request can have the exact same value shape as an old legacy booking that has &lt;em&gt;already&lt;/em&gt; been cancelled by a correction. Heal saw the shape match, decided the new request was a duplicate, and cancelled the legacy row a second time. Silently deflating a real person's balance.&lt;/p&gt;

&lt;p&gt;We fixed that one properly — or thought we did. The insight was that the signal separating the two cases is &lt;strong&gt;temporal&lt;/strong&gt;, and the source system already sends it: the legacy rows are a frozen copy taken at one instant, so they cannot contain an event that hadn't been initiated by then. A new row initiated &lt;em&gt;after&lt;/em&gt; its candidate legacy row was snapshotted is provably a different event. We added an &lt;code&gt;initiated&lt;/code&gt; timestamp to the schema, threaded it through the transformer, and gated heal on causality rather than coincidence. Three regression tests, one replaying a real three-sync sequence against production IDs.&lt;/p&gt;

&lt;p&gt;It was, honestly, a nice fix. It was also the moment the whole approach fell over.&lt;/p&gt;




&lt;h2&gt;
  
  
  The sentence that ended it
&lt;/h2&gt;

&lt;p&gt;While writing the commit message for bug 5, we wrote this to explain why no simpler rule would do:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No rule over those five columns can fix this — the identical shape also occurs when the heal&lt;br&gt;
&lt;strong&gt;is&lt;/strong&gt; correct, so any ordering preference gets one of the two cases wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that again with fresh eyes. It isn't a description of a bug. It's a statement that the mechanism cannot be made correct. &lt;code&gt;(employee_id, date, leave_type, unit, amount)&lt;/code&gt; takes the same value when two rows are the same underlying event &lt;strong&gt;and&lt;/strong&gt; when they are unrelated events that coincide. Two 8-hour sick days for the same person on the same date are indistinguishable in those columns whether one is a duplicate of the other or not.&lt;/p&gt;

&lt;p&gt;Not undecidable in the computer-science sense — this is more mundane and more annoying than a halting problem. The information needed to answer the question is simply not present in the data, and no amount of cleverness over five columns conjures it. Every fix we'd shipped was a better guess at a question that has no answer.&lt;/p&gt;

&lt;p&gt;Five root causes in two days, all living inside the same matching rule, and the fifth one's fix shipped with a written argument for why the rule can't be made sound. That's not a code quality problem. That's the design talking.&lt;/p&gt;

&lt;p&gt;So we stopped fixing and started measuring.&lt;/p&gt;




&lt;h2&gt;
  
  
  Measuring instead of fixing
&lt;/h2&gt;

&lt;p&gt;Three queries against production data:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compensating rows heal had ever written in production&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;…of those, &lt;strong&gt;wrong&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;…the 4 correct ones dated from&lt;/td&gt;
&lt;td&gt;the single sync right after the snapshot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Employee-days present in &lt;strong&gt;both&lt;/strong&gt; the frozen snapshot and the new feed&lt;/td&gt;
&lt;td&gt;244&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;…reconciling exactly under a &lt;strong&gt;no-heal-at-all&lt;/strong&gt; rule&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;238&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;…explained by a correct heal row&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;…unexplained&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New-feed rows ever &lt;strong&gt;mutated&lt;/strong&gt; across syncs (&lt;code&gt;updated_at&lt;/code&gt; trigger)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 of 1,299&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max &lt;code&gt;updated_at − created_at&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;8.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things fall straight out of that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Heal was net-negative in production.&lt;/strong&gt; Thirteen rows, out of roughly 1,300 the new feed had written. Nine wrong, four right, and all four right ones came from one sync immediately after the snapshot. That asymmetry is structural, not bad luck: genuine overlap is a &lt;em&gt;spent&lt;/em&gt; cutover artifact that decays to nothing, while coincidental value matches accrue forever, with every new request. The mechanism was guaranteed to get worse over time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Corrections are deltas, not restatements.&lt;/strong&gt; 238 of 244 overlapping employee-days reconcile exactly if you just let both rows stand and add them up. The HR platform doesn't restate a day's total; it emits signed adjustments that sum correctly. We had built compensation for a problem the source data already solved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rows were already immutable.&lt;/strong&gt; Zero of 1,299 rows had ever been updated across syncs. The 8.8 ms maximum gap between create and update means the only &lt;code&gt;DO UPDATE&lt;/code&gt; that ever fired did so &lt;em&gt;within a single batch&lt;/em&gt; — never across syncs. Our upsert's update branch existed purely to produce bugs.&lt;/p&gt;

&lt;p&gt;The bill for all this was nine hours of leave, deflated across two people's balances, deleted by hand once we understood them. Small, and it stayed small for an unsatisfying reason: the new table had no downstream readers yet, because the later phases of the migration hadn't run. The sync does publish each changed balance as an event, so the wrong numbers did leave the service — but the tables the wider business reads were still the legacy ones.&lt;/p&gt;

&lt;p&gt;Nothing alerted, either. Every one of those 13 rows was written by a sync that reported success. The only reason we know the number at all is that we'd built the parity harness.&lt;/p&gt;

&lt;p&gt;Which brings us to those two unexplained employee-days.&lt;/p&gt;




&lt;h2&gt;
  
  
  The twist
&lt;/h2&gt;

&lt;p&gt;One request from the HR platform, business process "Absence Correction", carried this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;entries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;            &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;13/08&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-8&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Hours;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;13/08&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Hours;&lt;/span&gt;
                     &lt;span class="s"&gt;14/08&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="nv"&gt;  &lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Hours;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;14/08&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-8&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Hours"&lt;/span&gt;
&lt;span class="na"&gt;total_units_hours&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four entries, two dates, netting zero on both — a cancel-and-rebook, which is exactly what a correction looks like.&lt;/p&gt;

&lt;p&gt;Our table's primary key was &lt;code&gt;(request_id, date)&lt;/code&gt;. Our transformer emitted one row per entry. So all four rows collided on two keys, and the sequential upsert kept the last write per date:&lt;/p&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;Entry&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;13/08 −8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;INSERT → &lt;code&gt;−8&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;13/08 +8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;conflict → UPDATE → &lt;strong&gt;&lt;code&gt;+8&lt;/code&gt;&lt;/strong&gt; overwrites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;14/08 +8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;INSERT → &lt;code&gt;+8&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;14/08 −8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;conflict → UPDATE → &lt;strong&gt;&lt;code&gt;−8&lt;/code&gt;&lt;/strong&gt; overwrites&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The balance we published was &lt;strong&gt;16 hours on the 13th and 0 on the 14th&lt;/strong&gt;. The truth was 8 and 8.&lt;/p&gt;

&lt;p&gt;No error. No warning. No failed row. And look at the last line of the payload: the source system sent us &lt;code&gt;total_units_hours: "0"&lt;/code&gt;, a checksum for the entire request, flatly contradicting what we had stored. We had never checked it.&lt;/p&gt;

&lt;p&gt;The design document for the table said, in as many words, that corrections "are modelled as separate requests, not as mutations of the original row", and therefore &lt;code&gt;(request_id, date)&lt;/code&gt; identifies exactly one value. That assumption was wrong. A single request can carry multiple entries for the same date, and any correction that cancels and re-books on the same day hits it.&lt;/p&gt;

&lt;p&gt;Worse, the bug destroys its own evidence. An overwritten row leaves nothing behind saying it was overwritten, so you cannot query the table to find out how often this happened. We still don't know.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the tests were green
&lt;/h2&gt;

&lt;p&gt;They were green because they were honest tests of the wrong thing.&lt;/p&gt;

&lt;p&gt;The transformer had unit tests. The repository had integration tests against real Postgres in a container, not a mock. Both passed throughout. Their fixtures were built from payloads we had actually seen — one entry per date, clean value shapes, the happy path — because that is what fixtures are: a record of what you already know.&lt;/p&gt;

&lt;p&gt;The four-entry correction wasn't in the fixture set because we didn't know that shape existed. Every bug in this post lived in the correction path, which is a few percent of volume, produces no errors when it's wrong, and is the part that matters most.&lt;/p&gt;

&lt;p&gt;So: the reconciliation step could not be made correct, &lt;em&gt;and&lt;/em&gt; the primary key it reconciled against was not unique. Two findings, one conclusion — this wasn't a patch job. Part 2 covers what we built instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we'd tell ourselves two days earlier
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When a fix requires proving no simpler rule works, the design is the bug.&lt;/strong&gt; We wrote a paragraph explaining why value-shape matching can't be made correct, and then shipped a sixth fix to it. That paragraph was the finding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch the shape of your fixes, not just their correctness.&lt;/strong&gt; Bug 4's fix was right, tested and idempotent, and it took the query from twenty lines to five CTEs. Rising complexity in the same spot is the cheapest signal you get that the model underneath is wrong, and it arrives before the proof does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Count the mechanism's production output before you fix it again.&lt;/strong&gt; "13 rows ever written, 9 of them wrong" took one query and ended a two-day argument. Any mechanism you're repeatedly patching can be measured, and the measurement is usually cheaper than the next fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask what happens if you delete it.&lt;/strong&gt; "238 of 244 overlapping employee-days reconcile with no compensation at all" was the single most valuable number we produced. The counterfactual query — what would this data look like if the code didn't exist — is badly underused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trust the checksums your source hands you.&lt;/strong&gt; The payload contained a total that contradicted what we stored. Free validation, ignored for months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent data loss doesn't leave evidence, so build the harness.&lt;/strong&gt; Nothing here threw an exception. Every wrong row was written by a green sync. The parity query is the only reason any of it is a story rather than a slowly-drifting table.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 2: the append-only ledger that replaced it, the one-line change that made the whole reconciliation problem disappear, and the cutover run that lost 235 employee-days.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>datamigration</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Static Site, Live Inventory: Two Sources of Truth That Don't Fight Each Other</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Thu, 04 Jun 2026 12:24:00 +0000</pubDate>
      <link>https://dev.to/dbartalos/static-site-live-inventory-two-sources-of-truth-that-dont-fight-each-other-5c0a</link>
      <guid>https://dev.to/dbartalos/static-site-live-inventory-two-sources-of-truth-that-dont-fight-each-other-5c0a</guid>
      <description>&lt;p&gt;The shop sells two things: original watercolour paintings (one of each, ever) and open-edition prints. Originals sell out permanently. Prints don't. The question the architecture has to answer is: how does the site know which originals are still available, and how quickly does it reflect a sale?&lt;/p&gt;

&lt;p&gt;The naive answer is to fetch from Medusa at build time and bake the sold status into the static HTML. That works until a painting sells between deploys — the site shows it as available, someone clicks "Add to cart," Medusa rejects the request, and the experience is broken. Rebuilding on every sale is an option, but it couples the storefront's uptime to Medusa's webhook reliability.&lt;/p&gt;

&lt;p&gt;The naive answer in the other direction is to fetch from Medusa client-side on every page load and render nothing until the data arrives. That's a spinner on a content site. The paintings are the product. Making visitors wait to see them is the wrong trade.&lt;/p&gt;

&lt;p&gt;The actual solution has two layers with clearly defined responsibilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: build-time hint
&lt;/h2&gt;

&lt;p&gt;Every painting in the content collection has an optional &lt;code&gt;sold&lt;/code&gt; field in its frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Autumn&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Morning"&lt;/span&gt;
&lt;span class="na"&gt;sold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;sold: true&lt;/code&gt;, the build-time effects are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Catalog feed&lt;/strong&gt;: the original variant is excluded from the Google/Meta product catalog TSV. No point advertising something that can't be bought.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON-LD&lt;/strong&gt;: the product structured data uses &lt;code&gt;OutOfStock&lt;/code&gt; for availability. Google doesn't index it as a purchasable product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OG tags&lt;/strong&gt;: &lt;code&gt;product:availability&lt;/code&gt; is set to &lt;code&gt;"oos"&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these affect the shop UI. A sold-out painting still has a page, still shows in the gallery, still shows the print options. The &lt;code&gt;sold&lt;/code&gt; flag is a &lt;em&gt;build-time signal to external systems&lt;/em&gt;, not a UI gate.&lt;/p&gt;

&lt;p&gt;The flag is kept in sync automatically. &lt;code&gt;bun run sync&lt;/code&gt; reads &lt;code&gt;stocked_quantity&lt;/code&gt; from Medusa for every product and patches the frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;↕ autumn-morning → sold: true
↕ winter-estuary → removed sold flag (back in stock)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;↕&lt;/code&gt; in the output means a patch was written. Running sync before deploying keeps the catalog and structured data accurate without manual frontmatter edits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: runtime hydration
&lt;/h2&gt;

&lt;p&gt;The shop grid is static Astro HTML. Every card renders with &lt;code&gt;data-status="available"&lt;/code&gt; by default — the optimistic assumption. The original price, the "Add to cart" button, the availability badge: all rendered at build time, all assuming the painting is available.&lt;/p&gt;

&lt;p&gt;After the page loads, a plain &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block calls the Medusa store API and patches each card:&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;hydrateAvailability&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;products&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;fetchProducts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// cached promise, fires once per page&lt;/span&gt;

  &lt;span class="nx"&gt;cards&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;card&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;availability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handle&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;originalAvailable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;availability&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;originalAvailable&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;// optimistic fallback&lt;/span&gt;

    &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;originalAvailable&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;available&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sold&lt;/span&gt;&lt;span class="dl"&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;originalAvailable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.variant-original&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-sold&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&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="nf"&gt;hydrateAvailability&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;data-sold&lt;/code&gt; on the original variant row triggers CSS: the price gets a strikethrough, the row fades slightly, the "Add to cart" button disappears. The card stays in the grid — sold originals are visible as FOMO and context, not hidden. Prints on the same painting remain fully purchasable.&lt;/p&gt;

&lt;p&gt;No framework. No Svelte island. No loading state. The grid is visible and interactive immediately; the sold indicators arrive silently a few hundred milliseconds later without shifting anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when Medusa is down
&lt;/h2&gt;

&lt;p&gt;The optimistic fallback on line 6 above is intentional. If &lt;code&gt;fetchProducts()&lt;/code&gt; fails — Medusa is restarting, the VPS is briefly unreachable — every card stays &lt;code&gt;data-status="available"&lt;/code&gt;. Visitors can browse. The cart still works. If someone tries to add a sold original, Medusa rejects the cart request and the UI shows the error then.&lt;/p&gt;

&lt;p&gt;The worst case is a few extra "sorry, sold out" cart errors during a brief backend outage. The alternative — blocking the grid on a Medusa response — would mean a broken shop during any downtime. For a low-traffic art shop, optimistic-with-graceful-degradation is the right default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The filter system
&lt;/h2&gt;

&lt;p&gt;The shop grid has filters: show all / available originals only / prints only / by theme. The filters use &lt;code&gt;data-hidden&lt;/code&gt; (not &lt;code&gt;display:none&lt;/code&gt;) driven by &lt;code&gt;data-status&lt;/code&gt; and &lt;code&gt;data-tags&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyFilters&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cards&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;card&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;shouldHide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;activeFilter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;activeTags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hidden&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;CSS transitions on &lt;code&gt;data-hidden&lt;/code&gt; give the appearance/disappearance a fade rather than a jump. The "available originals" filter count updates after &lt;code&gt;hydrateAvailability()&lt;/code&gt; completes — it reads the live &lt;code&gt;data-status&lt;/code&gt; values, not the build-time assumptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two layers instead of one
&lt;/h2&gt;

&lt;p&gt;Build-time data is fast and free — it's just frontmatter. Runtime data is live and accurate. The split is about matching the right data source to the right consumer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;External systems (Google, Meta) see the build-time state. They cache it. A slightly-stale catalog entry means a sold original might briefly appear in Google Shopping — a bad click, but not a broken purchase (Medusa rejects the cart). Running &lt;code&gt;bun run sync&lt;/code&gt; before each deploy keeps the gap small.&lt;/li&gt;
&lt;li&gt;The shop UI sees the runtime state. A "available" badge on an actually-sold painting is a much worse experience — someone browses, adds to cart, gets rejected. That one must be live.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;bun run sync&lt;/code&gt; closes the gap before each deploy, so the build-time state is never more than one deploy behind. In practice, originals sell slowly enough that the gap is rarely more than a few hours.&lt;/p&gt;

&lt;p&gt;If you're applying this on a different stack, the principle generalises: optimistic static HTML + silent runtime patch + accept that the worst case is a cart error. The trick is being honest about which data source serves which consumer, and not making the visitor wait for the slow one.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this pattern doesn't fit
&lt;/h2&gt;

&lt;p&gt;Optimistic-with-graceful-degradation is the right default for a low-traffic shop where originals sell on the order of days. It's the wrong default for a flash sale, a sneaker drop, or anything where stock depletes in seconds and a misleading "available" badge would mean hundreds of cart errors a minute. At that point the spinner stops being a UX failure and starts being an honest signal — "we're checking, because the answer changes faster than we can ship HTML."&lt;/p&gt;




&lt;p&gt;&lt;em&gt;That's the full series. The stack behind &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt;: Astro 6 + Svelte on Cloudflare Pages, Medusa v2 on a €3.29/mo Hetzner VPS, and a handful of patterns that took longer to figure out than they should have.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>medusa</category>
      <category>ecommerce</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Transactional Email in Medusa v2 Without the Notification Module</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Tue, 02 Jun 2026 12:18:00 +0000</pubDate>
      <link>https://dev.to/dbartalos/transactional-email-in-medusa-v2-without-the-notification-module-4gen</link>
      <guid>https://dev.to/dbartalos/transactional-email-in-medusa-v2-without-the-notification-module-4gen</guid>
      <description>&lt;p&gt;Medusa v2 has a notification module. It's designed exactly for transactional email — you register a provider, configure templates, and the module fires on order events. There's one problem: there's no official Resend provider.&lt;/p&gt;

&lt;p&gt;The community providers that exist are hit-and-miss on maintenance. The official SendGrid provider exists, but its setup felt heavier than the problem warranted. Resend, on the other hand, seemed easy enough to implement directly — clean API, good TypeScript types, and 3,000 emails a month free and a dollar per thousand beyond that.&lt;/p&gt;

&lt;p&gt;This is what I did instead: skip the notification module entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The approach
&lt;/h2&gt;

&lt;p&gt;Medusa's event system still works without the notification module. Any subscriber can listen to &lt;code&gt;order.placed&lt;/code&gt; and do whatever it wants. So the setup is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An &lt;code&gt;order.placed&lt;/code&gt; subscriber that calls the Resend SDK directly.&lt;/li&gt;
&lt;li&gt;The email template inlined in the subscriber file.&lt;/li&gt;
&lt;li&gt;A dev-redirect pattern so order emails in staging don't land in real inboxes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No notification module configuration. No provider abstraction. Just a function that runs when an order is placed and sends an email.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ESM trap: why the template lives inline
&lt;/h2&gt;

&lt;p&gt;The natural instinct is to put the HTML template in a separate file and import 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="c1"&gt;// ❌ This fails at runtime&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;orderConfirmationTemplate&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;../templates/order-confirmation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Medusa's API uses &lt;code&gt;"module": "Node16"&lt;/code&gt;. Cross-file imports for subscriber dependencies work in dev and fail in production with &lt;code&gt;ERR_MODULE_NOT_FOUND&lt;/code&gt;. I didn't fully diagnose which resolver did what — inlining everything sidesteps the question entirely.&lt;/p&gt;

&lt;p&gt;The fix is to keep subscriber files completely self-contained. Everything the subscriber needs — template, helpers, types — lives in the same file.&lt;/p&gt;

&lt;p&gt;The same rule applies to &lt;code&gt;import type&lt;/code&gt;. With &lt;code&gt;"module": "Node16"&lt;/code&gt;, plain &lt;code&gt;import { SomeType }&lt;/code&gt; for TypeScript types isn't guaranteed to be erased from the compiled output. Always use &lt;code&gt;import type { ... }&lt;/code&gt; for type-only imports in subscriber files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The subscriber
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// api/src/subscribers/order-placed.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SubscriberArgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SubscriberConfig&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;@medusajs/framework&lt;/span&gt;&lt;span class="dl"&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;Modules&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;@medusajs/framework/utils&lt;/span&gt;&lt;span class="dl"&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;Resend&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;resend&lt;/span&gt;&lt;span class="dl"&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;orderPlaced&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;container&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;SubscriberArgs&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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="kr"&gt;string&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orderModule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Modules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ORDER&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;order&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;orderModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieveOrder&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;isDev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&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="nx"&gt;PUBLIC_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;development&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toAddress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isDev&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;process&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="nx"&gt;EMAIL_DEV_REDIRECT&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Resend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&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="nx"&gt;RESEND_API_KEY&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;resend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&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;Nadia Poe &amp;lt;hello@nadiapoe.co.uk&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;toAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Order confirmed — &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display_id&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="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;buildOrderEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SubscriberConfig&lt;/span&gt; &lt;span class="o"&gt;=&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order.placed&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;function&lt;/span&gt; &lt;span class="nf"&gt;buildOrderEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&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="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// template inlined here — plain HTML string, no JSX, no template engine&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!DOCTYPE html&amp;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 &lt;code&gt;EMAIL_DEV_REDIRECT&lt;/code&gt; pattern is worth keeping. In development, every order confirmation — regardless of who placed the order — goes to a &lt;code&gt;+dev&lt;/code&gt; alias on your own email address. You see the real email, the real order data, without polluting a customer inbox. In production, &lt;code&gt;toAddress&lt;/code&gt; is the customer's email. The guard is a single ternary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contact form: no SDK needed
&lt;/h2&gt;

&lt;p&gt;The storefront has a contact form that also sends via Resend. For a one-off form POST, installing the Resend SDK client-side isn't worth it — the API is just an HTTP endpoint:&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;// client/src/pages/api/contact.ts&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;env&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;cloudflare:workers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;APIRoute&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &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="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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&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;request&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;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.resend.com/emails&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &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="nx"&gt;RESEND_CONTACT_API_KEY&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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;body&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;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&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;nadiapoe.co.uk &amp;lt;hello@nadiapoe.co.uk&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;to&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="nx"&gt;EMAIL_CONTACT_TO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reply_to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Message from &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&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="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&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;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&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;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;204&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;env&lt;/code&gt; comes from &lt;code&gt;cloudflare:workers&lt;/code&gt; — the live runtime binding, not &lt;code&gt;import.meta.env&lt;/code&gt;. The &lt;code&gt;reply_to&lt;/code&gt; is set to the sender's address so replying in Gmail works naturally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four API keys, not one
&lt;/h2&gt;

&lt;p&gt;One API key for everything is convenient until you need to rotate it, audit usage, or debug which part of the system sent a bad email. The setup uses four keys:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Used by&lt;/th&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;medusa-notifications-prod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;order subscriber&lt;/td&gt;
&lt;td&gt;production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;medusa-notifications-dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;order subscriber&lt;/td&gt;
&lt;td&gt;staging&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;storefront-contact-prod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;contact form&lt;/td&gt;
&lt;td&gt;production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;storefront-contact-dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;contact form&lt;/td&gt;
&lt;td&gt;staging&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Resend shows per-key send history. When something goes wrong you can see exactly which key sent what, without digging through logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS: the boring bit that breaks everything if you skip it
&lt;/h2&gt;

&lt;p&gt;Resend's Cloudflare integration auto-configures DKIM and SPF. The setup that works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SPF&lt;/strong&gt;: &lt;code&gt;v=spf1 include:_spf.resend.com ~all&lt;/code&gt; on the root domain&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DKIM&lt;/strong&gt;: Resend generates the records; Cloudflare auto-applies them via the integration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DMARC&lt;/strong&gt;: &lt;code&gt;v=DMARC1; p=reject; rua=mailto:hello@nadiapoe.co.uk&lt;/code&gt; — &lt;code&gt;p=reject&lt;/code&gt; means unauthenticated mail claiming to be from &lt;code&gt;nadiapoe.co.uk&lt;/code&gt; gets dropped, not delivered&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MX + inbound&lt;/strong&gt;: Cloudflare Email Routing with a catch-all rule forwarding to Gmail. &lt;code&gt;hello@nadiapoe.co.uk&lt;/code&gt; works as a real inbox without running a mail server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Resend SMTP credentials (&lt;code&gt;smtp.resend.com:587&lt;/code&gt;, username &lt;code&gt;resend&lt;/code&gt;, password = the API key) let Gmail send &lt;em&gt;as&lt;/em&gt; &lt;code&gt;hello@nadiapoe.co.uk&lt;/code&gt; via "Send mail as" — so replies from the shop's inbox come from the right address.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you give up
&lt;/h2&gt;

&lt;p&gt;The notification module's abstraction is useful if you ever want to swap providers or add multiple notification channels (email + SMS + push). Bypassing it means that flexibility lives in your subscriber code instead. For a shop that will always send via Resend, that's a fine trade. If the requirements change, the migration is straightforward: add the official provider when it ships, move the template, delete the subscriber.&lt;/p&gt;

&lt;p&gt;Live shop: &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>medusa</category>
      <category>email</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Three Cloudflare Patterns Earned the Hard Way</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Thu, 28 May 2026 12:13:00 +0000</pubDate>
      <link>https://dev.to/dbartalos/three-cloudflare-patterns-earned-the-hard-way-1pcc</link>
      <guid>https://dev.to/dbartalos/three-cloudflare-patterns-earned-the-hard-way-1pcc</guid>
      <description>&lt;p&gt;Every Cloudflare product is well-documented in isolation. The interesting bugs are always at the seams between two products — the edge injecting scripts into HTML that already has a CSP, the WAF inspecting requests for media served from R2, Vite substituting variables at build time that don't exist yet on Pages. These three patterns are from running &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt; on Astro 6 + Pages, with R2 hosting the artwork media.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. CSP nonces via middleware, not build-time hashes
&lt;/h2&gt;

&lt;p&gt;The textbook way to do a strict CSP on a static site is build-time hashing: scan every inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;, hash it, list the hashes in the CSP header. Cloudflare even has a build hook to do it for you.&lt;/p&gt;

&lt;p&gt;For an art site, Bot Fight Mode is non-negotiable — automated scrapers harvesting painting images are a real concern, and Cloudflare's challenge platform is the cheapest layer of protection available. But it breaks the moment you enable it. Cloudflare's edge injects the challenge widget at request time with a rotating token. The hash changes per request. Your CSP rejects it. Half your visitors get a broken challenge widget because the static hash you baked in this morning no longer matches.&lt;/p&gt;

&lt;p&gt;The fix is a per-request nonce. Astro 6's middleware runs as a Worker; Workers have &lt;code&gt;HTMLRewriter&lt;/code&gt;. From &lt;code&gt;client/src/middleware.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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineMiddleware&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="nx"&gt;context&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="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;response&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;next&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;HTMLRewriter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&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;response&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&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;response&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonceBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&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;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;nonceBytes&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;rewritten&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HTMLRewriter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&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;element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nonce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonce&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="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&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;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rewritten&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Security-Policy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;buildCsp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="c1"&gt;// private cache — never share a nonce between users&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;private, max-age=1500, stale-while-revalidate=7200&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rewritten&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rewritten&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&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 non-obvious bits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare reads the nonce from your CSP header and stamps its own injected scripts with it.&lt;/strong&gt; Undocumented but stable. This is the only reason the pattern works at all — without it, the challenge widget would still get blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Cache-Control: private&lt;/code&gt; is load-bearing.&lt;/strong&gt; A shared cache that served one user's nonced HTML to another would only break the cached client (their nonce doesn't match the newly injected scripts), but it's still a bug. &lt;code&gt;private&lt;/code&gt; keeps Cloudflare's edge from doing this.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Server secrets via &lt;code&gt;cloudflare:workers&lt;/code&gt;, not &lt;code&gt;import.meta.env&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Astro has two environments: build-time and runtime. Vite resolves &lt;code&gt;import.meta.env.SOMETHING&lt;/code&gt; at build time by string substitution. If the value isn't in &lt;code&gt;.env&lt;/code&gt; at build time — and Cloudflare Pages doesn't expose dashboard-configured secrets during the build — Vite bakes in the literal &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Worse: it does this silently. No warning, no error. The contact form deploys, the Resend SDK call fails with "API key undefined," and you spend an hour checking the Cloudflare dashboard before realising the value never made it into the bundle.&lt;/p&gt;

&lt;p&gt;Astro 6 has two ways to read server-side env at runtime. They are &lt;em&gt;not&lt;/em&gt; equivalent:&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;// ❌ Broken in Astro 6 — Astro.locals.runtime.env was removed&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Astro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&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="nx"&gt;RESEND_API_KEY&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Works&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;env&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;cloudflare:workers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API_KEY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cloudflare:workers&lt;/code&gt; module is provided by the Workers runtime. &lt;code&gt;env&lt;/code&gt; is the live binding object — the same one your &lt;code&gt;wrangler.toml&lt;/code&gt; and Cloudflare dashboard configure. No build-time substitution; nothing is baked in.&lt;/p&gt;

&lt;p&gt;Client-side code (anything in a Svelte component or a non-SSR Astro page) keeps using &lt;code&gt;import.meta.env.PUBLIC_*&lt;/code&gt;. Those are baked at build time on purpose — they're public.&lt;/p&gt;

&lt;p&gt;One gotcha: bindings may be absent in local dev without a full Pages emulation setup. Wrap access in try/catch when the value is optional:&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;getDb&lt;/span&gt;&lt;span class="p"&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;DB&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. R2 hotlink protection with an inverted WAF rule
&lt;/h2&gt;

&lt;p&gt;Print fulfillment runs through Prodigi — a print-on-demand provider that produces and ships prints worldwide. When an order comes in, Medusa generates a time-gated presigned R2 URL pointing to the high-resolution print master and hands it to Prodigi. Prodigi fetches the file, prints it, ships it. The URL expires. Nobody else ever needs access to that file.&lt;/p&gt;

&lt;p&gt;The high-res masters live in R2. Keeping them protected means ensuring those presigned URLs can only be opened by the intended recipient in the intended window — not scraped, not re-shared, not embedded on another site. The WAF rule is the layer that enforces origin intent on the media domain.&lt;/p&gt;

&lt;p&gt;The naive WAF rule is "block if the &lt;code&gt;Referer&lt;/code&gt; header doesn't match &lt;code&gt;nadiapoe.co.uk&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;It works until you try to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open an image URL directly in a browser tab&lt;/strong&gt; — no Referer, blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load a &lt;code&gt;&amp;lt;video preload="metadata"&amp;gt;&lt;/code&gt; tag&lt;/strong&gt; — some browsers send no Referer on media requests, blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on a &lt;code&gt;*.pages.dev&lt;/code&gt; preview deployment&lt;/strong&gt; — Referer is &lt;code&gt;&amp;lt;branch&amp;gt;.nadiapoe-co-uk.pages.dev&lt;/code&gt;, blocked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is to invert the logic: block &lt;em&gt;only when&lt;/em&gt; a Referer exists &lt;em&gt;and&lt;/em&gt; isn't in the allowlist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(http.host eq "media.nadiapoe.co.uk")
and (len(http.referer) &amp;gt; 0)
and not (http.referer contains "nadiapoe.co.uk")
and not (http.referer contains "localhost")
and not (http.referer contains ".pages.dev")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty Referers pass — direct nav, preload requests, RSS readers. Preview deployments pass via the &lt;code&gt;.pages.dev&lt;/code&gt; wildcard. Real hotlinks from other sites get a 403.&lt;/p&gt;

&lt;p&gt;If you script your WAF rules via the Cloudflare API (which you should, for reproducibility), two quirks not mentioned in the error messages: a &lt;code&gt;PUT&lt;/code&gt; to the rulesets endpoint must &lt;em&gt;not&lt;/em&gt; include &lt;code&gt;kind&lt;/code&gt; or &lt;code&gt;phase&lt;/code&gt; (they're implicit from the URL), and rate-limit &lt;code&gt;characteristics&lt;/code&gt; on the free plan must include &lt;code&gt;cf.colo.id&lt;/code&gt; because counts are per-colo, not global.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The seams are where you learn things. Check the network tab when behaviour is wrong — Cloudflare adds its own response headers that tell you which rule fired, which challenge ran, which WAF block triggered. The answers are usually there; they're just not in the docs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt; runs on all three patterns in production.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>astro</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Medusa v2 in Production: Three Bugs That Each Ate a Weekend</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Tue, 26 May 2026 12:13:00 +0000</pubDate>
      <link>https://dev.to/dbartalos/medusa-v2-in-production-three-bugs-that-each-ate-a-weekend-4e67</link>
      <guid>https://dev.to/dbartalos/medusa-v2-in-production-three-bugs-that-each-ate-a-weekend-4e67</guid>
      <description>&lt;p&gt;Production bugs don't care that your infrastructure costs €3.29/mo.&lt;/p&gt;

&lt;p&gt;Medusa v2 is genuinely good — the headless model, the workflow engine, the v2 admin API are all a step up from v1. But the docs surface 80% of what you'll hit, and the remaining 20% is where weekends go. These are three bugs I hit running &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt;'s shop on Medusa v2. All three had simple fixes. None of the fixes were obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: The &lt;code&gt;Date.parse&lt;/code&gt; shipping rule trap
&lt;/h2&gt;

&lt;p&gt;The shop has two delivery options — a small incentive to encourage slightly larger print orders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Standard delivery&lt;/strong&gt; — £4.50, always available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free delivery&lt;/strong&gt; — £0, available when &lt;code&gt;cart_subtotal &amp;gt;= 60&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A £100 cart. Free Delivery disappears. Standard gets auto-selected. Refresh, clear cart, retry — same result. Stranger: a £10 test cart shows Free Delivery correctly. The rule is set up right. The data in the database looks right. Something in the middle is broken.&lt;/p&gt;

&lt;p&gt;That something is buried in &lt;code&gt;@medusajs/fulfillment/dist/utils/index.js&lt;/code&gt;. The comparator for &lt;code&gt;gte&lt;/code&gt; / &lt;code&gt;lt&lt;/code&gt; rules does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&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;right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&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="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;right&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// date branch&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;// numeric branch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks fine until you remember what &lt;code&gt;Date.parse&lt;/code&gt; does to bare integer strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;60&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;// → -315619200000     (year 1960 — 2-digit year expansion)&lt;/span&gt;
&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;100&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// → -59011459125000  (year 100 AD)&lt;/span&gt;
&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;60.00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// → NaN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A £100 cart with &lt;code&gt;cart_subtotal = "100"&lt;/code&gt; and a rule value of &lt;code&gt;"60"&lt;/code&gt; evaluates as &lt;code&gt;Date(100 AD) &amp;lt; Date(1960)&lt;/code&gt; → &lt;code&gt;true&lt;/code&gt;. The "show this option when subtotal is &lt;em&gt;below&lt;/em&gt; £60" condition fires on a cart worth nearly twice the threshold. Free Delivery gets stripped.&lt;/p&gt;

&lt;p&gt;The fix is one &lt;code&gt;toFixed(2)&lt;/code&gt;. From &lt;code&gt;api/src/workflows/shipping-options-context.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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;listShippingOptionsForCartWorkflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;listShippingOptionsForCartWithPricingWorkflow&lt;/span&gt;&lt;span class="p"&gt;,&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="s2"&gt;@medusajs/medusa/core-flows&lt;/span&gt;&lt;span class="dl"&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;StepResponse&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="s2"&gt;@medusajs/framework/workflows-sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;injectCartSubtotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;cart&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="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StepResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cart_subtotal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;item_total&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="nf"&gt;toFixed&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="p"&gt;})&lt;/span&gt;

&lt;span class="nx"&gt;listShippingOptionsForCartWorkflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setShippingOptionsContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;injectCartSubtotal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;listShippingOptionsForCartWithPricingWorkflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setShippingOptionsContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;injectCartSubtotal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule values stored in the database have to match the same shape — &lt;code&gt;"60.00"&lt;/code&gt;, not &lt;code&gt;"60"&lt;/code&gt;. Once both sides are decimals, &lt;code&gt;Date.parse&lt;/code&gt; returns &lt;code&gt;NaN&lt;/code&gt; and the evaluator falls through to the numeric branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: There are &lt;em&gt;two&lt;/em&gt; shipping workflows, and you have to hook both
&lt;/h2&gt;

&lt;p&gt;Look at the snippet above again. There are two workflow hooks, not one. That's not an accident.&lt;/p&gt;

&lt;p&gt;By default Medusa v2's shipping rule evaluator only sees &lt;code&gt;is_return&lt;/code&gt; and &lt;code&gt;enabled_in_store&lt;/code&gt;. Anything cart-financial — &lt;code&gt;cart_subtotal&lt;/code&gt;, &lt;code&gt;item_count&lt;/code&gt;, currency, region — has to be injected via the &lt;code&gt;setShippingOptionsContext&lt;/code&gt; hook. That part is documented.&lt;/p&gt;

&lt;p&gt;What isn't documented: there are &lt;strong&gt;two&lt;/strong&gt; workflows that evaluate shipping options.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;listShippingOptionsForCartWorkflow&lt;/code&gt; runs when the storefront fetches options to display.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;listShippingOptionsForCartWithPricingWorkflow&lt;/code&gt; runs &lt;em&gt;inside&lt;/em&gt; &lt;code&gt;addShippingMethodToCartWorkflow&lt;/code&gt; when a customer actually selects an option.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hook only the first and the storefront shows Free Delivery correctly. The customer selects it, gets a 400 back — because the second workflow has no &lt;code&gt;cart_subtotal&lt;/code&gt; in its context, the rule fails, and Medusa rejects the assignment as "option not available for this cart." The response body is generic. The server logs are silent.&lt;/p&gt;

&lt;p&gt;Cost: one Friday evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: The workaround that aged badly
&lt;/h2&gt;

&lt;p&gt;Some workarounds need an expiry date. This one didn't have one.&lt;/p&gt;

&lt;p&gt;Early Medusa v2 had two related bugs — &lt;a href="https://github.com/medusajs/medusa/issues/11766" rel="noopener noreferrer"&gt;#11766&lt;/a&gt; and &lt;a href="https://github.com/medusajs/medusa/issues/13301" rel="noopener noreferrer"&gt;#13301&lt;/a&gt;. Stripe charged the card, webhooks fired, but &lt;code&gt;order.paid_total&lt;/code&gt; stayed at 0 and the payment status never advanced past &lt;code&gt;pending&lt;/code&gt;. Every order had to be manually marked paid in the admin — which also meant no order confirmation email fired, since the email subscriber was gated on a completed order. Customers paid, heard nothing, and had to be chased manually.&lt;/p&gt;

&lt;p&gt;I patched it with a subscriber on &lt;code&gt;order.placed&lt;/code&gt; that called &lt;code&gt;capturePaymentWorkflow&lt;/code&gt; directly:&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;// api/src/subscribers/order-capture-payment.ts  (DELETED in 2026-05)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&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;orderCapturePayment&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;container&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...fetch payment collection, mark fully captured, refresh order&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked. Shipped.&lt;/p&gt;

&lt;p&gt;Then Medusa v2.11.1 landed and fixed both upstream bugs. The subscriber kept running — double-firing the capture (harmless, since the payment was already captured) but also re-emitting &lt;code&gt;order.placed&lt;/code&gt;. That re-emission triggered the email subscriber a second time. Every customer started getting two identical confirmation emails.&lt;/p&gt;

&lt;p&gt;I didn't notice until v2.14.2, months later, when an unrelated change made the second &lt;code&gt;order.placed&lt;/code&gt; event log a warning. The fix was a one-line deletion. The lesson cost more than the bug did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every workaround for a third-party bug needs three things:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The upstream issue URL in a comment — so future-you knows why it exists.&lt;/li&gt;
&lt;li&gt;A version check or feature flag to disable it — so it can be switched off without deleting it immediately.&lt;/li&gt;
&lt;li&gt;A note to revisit on the next major upgrade of that dependency.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I had #1. I didn't have #2 or #3. The duplicate emails probably did 30 minutes of brand damage before I caught it. The fix was free. The discipline to set expiry dates on workarounds isn't instinctive, but it's cheap.&lt;/p&gt;

&lt;p&gt;One note if you're starting a Medusa v2 project: the v1 docs URL still resolves and Google ranks both. Make sure the URL doesn't have &lt;code&gt;/v1/&lt;/code&gt; in it before you copy a snippet — the APIs changed significantly between versions.&lt;/p&gt;

&lt;p&gt;Live shop: &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>medusa</category>
      <category>ecommerce</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Hosting Rejection Tour: Render, AWS EC2, Oracle, and How I Ended Up on a €3.29/mo VPS</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Tue, 19 May 2026 12:17:00 +0000</pubDate>
      <link>https://dev.to/dbartalos/the-hosting-rejection-tour-render-aws-ec2-oracle-and-how-i-ended-up-on-a-eu329mo-vps-lp2</link>
      <guid>https://dev.to/dbartalos/the-hosting-rejection-tour-render-aws-ec2-oracle-and-how-i-ended-up-on-a-eu329mo-vps-lp2</guid>
      <description>&lt;p&gt;Before the stack worked, three hosting options failed. Each one looked perfect on paper. Each one had a specific, concrete reason it couldn't work. This is the rejection tour.&lt;/p&gt;

&lt;p&gt;The constraint: Medusa v2 is stateful. It needs PostgreSQL, Redis for BullMQ, and enough RAM to run the event bus and workflow engine without getting killed. That rules out serverless. (Medusa can technically start without Redis — it falls back to an in-process event bus — but that means losing job queuing, workflow retries, and reliable event delivery. Fine for a quick local demo; not something you want processing real orders.) It needs persistent storage, a real process manager, and a consistent IP for outbound webhook validation. Every "just deploy it for free" option assumes your workload is stateless. This one isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 1: Render
&lt;/h2&gt;

&lt;p&gt;Render's free tier looked fine for a low-traffic shop. 512MB RAM, easy deploys from GitHub — cold starts on the free tier, always-on on the paid plan. The plan was to pair it with Neon for PostgreSQL (generous free tier, serverless scaling) and Upstash for Redis (free tier covers BullMQ at low volume). Three managed services, total cost: $0.&lt;/p&gt;

&lt;p&gt;I got Medusa running, ran the setup scripts, and then installed the Prodigi print fulfillment plugin — which loads product mappings and prefetches shipping zones at startup.&lt;/p&gt;

&lt;p&gt;OOM kill on boot. 512MB wasn't enough.&lt;/p&gt;

&lt;p&gt;The obvious fix is to upgrade the Render service. Their starter plan is $7/mo. That's just the API — Neon and Upstash stay free at low volume, but now I'm at $7/mo for a single service with no room to grow, and I still hadn't solved staging. Render's starter plan is $7/mo &lt;em&gt;per service&lt;/em&gt;. Two environments means two API instances: $14/mo before touching the databases.&lt;/p&gt;

&lt;p&gt;I could have stripped the fulfillment plugin to fit inside 512MB. But without it there are no print orders — Prodigi integration is how open-edition prints get produced and shipped. Dropping it to hit a RAM limit means the shop sells originals only, which wasn't the brief.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 2: AWS EC2
&lt;/h2&gt;

&lt;p&gt;After Render, AWS EC2 looked like the right move. A &lt;code&gt;t4g.micro&lt;/code&gt; in &lt;code&gt;eu-west-2&lt;/code&gt; (London): 1 GiB RAM, 2 vCPU ARM64 Graviton2, always-on, full SSH access, swap configurable — all within the AWS free tier for the first 12 months. Double the memory of Render's paid plan, at no cost. I built out the full setup: CloudFormation stack, Nginx reverse proxy, Let's Encrypt TLS, systemd service, GitHub Actions CD, health check cron.&lt;/p&gt;

&lt;p&gt;It ran. There were a couple of ARM64 quirks — a &lt;code&gt;ts-node&lt;/code&gt; source-map crash under Bun that needed &lt;code&gt;TS_NODE_SKIP_SOURCE_MAP_SUPPORT=1&lt;/code&gt;, and Node.js auto-limiting the V8 heap to ~512MB on a 1 GiB instance, fixed with &lt;code&gt;NODE_OPTIONS=--max-old-space-size=1024&lt;/code&gt;. But Medusa came up, Prodigi loaded, the health check passed.&lt;/p&gt;

&lt;p&gt;Then I ran a checkout flow under load. OOM kill.&lt;/p&gt;

&lt;p&gt;The 1 GiB ceiling wasn't enough headroom when Prodigi, Medusa, and an active checkout were all competing for memory at once. And the infrastructure complexity — CloudFormation, Nginx config, cert renewal timers, CloudWatch, a manual cutover checklist — was a real ongoing cost for a solo project. After the free year, the bill would be ~$7.90/mo anyway. At that price you can just buy a VPS that has the memory you need and none of the ceremony.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 3: Oracle Always Free
&lt;/h2&gt;

&lt;p&gt;Oracle's Always Free tier is, on paper, absurd. The A1 Flex shape gives you up to 4 OCPU and 24 GB RAM at no cost, permanently. ARM64, which is what Hetzner's cheapest VPS uses anyway. UK London region available. I signed up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The selected shape is not available in this Availability Domain. Please try a different Availability Domain or shape."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I tried every Availability Domain in UK London. Same message. I tried Frankfurt. Same message. I tried Amsterdam. Same.&lt;/p&gt;

&lt;p&gt;This isn't a new problem. The Oracle community forum has threads going back two years with hundreds of people reporting A1 capacity unavailable in every European region. Oracle adds capacity occasionally, and it disappears within hours as people snap it up. The forum advice is to script retries and keep trying. I tried that for a few weeks.&lt;/p&gt;

&lt;p&gt;The Always Free tier only works if you can actually provision the instance. If capacity is gone, it's gone indefinitely. For a project you want to actually ship, "keep retrying and hope" isn't a deployment strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually worked: Hetzner CAX11
&lt;/h2&gt;

&lt;p&gt;Hetzner's CAX11 is €3.29/mo. ARM64, 2 vCPU, 4 GB RAM, 40 GB NVMe SSD, 20 TB/month egress included. I signed up, got the server provisioned in about 30 seconds, and had Medusa running the same afternoon.&lt;/p&gt;

&lt;p&gt;The setup that's been stable since:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two systemd services, one box.&lt;/strong&gt; Dev and production run as separate Medusa instances — different ports, different &lt;code&gt;.env&lt;/code&gt; files, different PostgreSQL databases. Caddy sits in front and routes &lt;code&gt;api-dev.nadiapoe.co.uk&lt;/code&gt; and &lt;code&gt;api.nadiapoe.co.uk&lt;/code&gt; to the right one. Each service restarts automatically on failure. No Docker, no orchestration — just two &lt;code&gt;medusa.service&lt;/code&gt; unit files and a Caddy config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploys via GitHub Actions.&lt;/strong&gt; On push to &lt;code&gt;main&lt;/code&gt;, a workflow SSHes in, pulls the latest code, runs &lt;code&gt;bun install&lt;/code&gt; and &lt;code&gt;bun run build&lt;/code&gt;, and restarts the appropriate service. Under 60 seconds end to end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups to Cloudflare R2.&lt;/strong&gt; A nightly cron runs &lt;code&gt;pg_dump&lt;/code&gt; and uploads the compressed archive to a dedicated R2 bucket (&lt;code&gt;nadiapoe-backups&lt;/code&gt;). R2 is already in the stack for media — adding a backup bucket costs nothing extra. Retention is 7 daily dumps on-server, 30 in R2. If the VPS burns down, restoring is &lt;code&gt;pg_restore&lt;/code&gt; and a &lt;code&gt;git pull&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The total monthly cost: €3.29. Both environments. All services. Backups included.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;"Free" hosting for stateful backend workloads usually means one of two things: a RAM ceiling that kills anything real, or a capacity queue where "available" means "available when someone else cancels." For a stateless frontend or a simple API, free tiers are great — Cloudflare Pages handles the storefront for nothing. But the moment you have a process that needs to stay alive, own persistent state, and run at startup, a cheap paid VPS is more reliable than a free tier with asterisks.&lt;/p&gt;

&lt;p&gt;Hetzner CAX11 at €3.29/mo is less than most people spend on a coffee a month. It's not free. It's better than free.&lt;/p&gt;

&lt;p&gt;One genuine acknowledgement before moving on: the free tiers from Cloudflare, Resend, Neon, and Upstash make it possible to build and prove a business before it earns a penny. They lower the bar for anyone who wants to try something without betting money on it first. That matters, and it's worth saying.&lt;/p&gt;

&lt;p&gt;Live shop: &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>medusa</category>
      <category>selfhosted</category>
      <category>hetzner</category>
    </item>
    <item>
      <title>Blazingly Fast Ecommerce Stack for Less Than a Coffee a Month — No Marketplace, No Platform Cut</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Mon, 18 May 2026 06:30:00 +0000</pubDate>
      <link>https://dev.to/dbartalos/blazingly-fast-ecommerce-stack-for-less-than-a-coffee-a-month-no-marketplace-no-platform-cut-59dn</link>
      <guid>https://dev.to/dbartalos/blazingly-fast-ecommerce-stack-for-less-than-a-coffee-a-month-no-marketplace-no-platform-cut-59dn</guid>
      <description>&lt;p&gt;If you've ever looked at a marketplace's fee page and felt your eye twitch, this post is for you.&lt;/p&gt;

&lt;p&gt;The major selling platforms take their cut from every angle — transaction fees, listing fees, monthly subscriptions, payment processing. The percentages vary but the direction doesn't: a meaningful slice of every sale goes to infrastructure you don't own or control. And that's before the visibility problem: on a marketplace of millions of listings, the algorithm decides whether your work gets seen at all.&lt;/p&gt;

&lt;p&gt;My girlfriend had been listing her work on one of the big marketplaces for a while — barely any traffic, zero sales. The fees were almost beside the point. I saw the disappointment and floated the idea: her own site, her own corner of the internet — and I'd build it.&lt;/p&gt;

&lt;p&gt;I'm a software engineer. I like a challenge. So I set out to give it a shot. The result runs at &lt;strong&gt;€3.29/mo&lt;/strong&gt; for all backend infrastructure. The site is live at &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt;. Here's the stack and the decisions behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraints
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Artwork loads instantly.&lt;/strong&gt; Watercolours are the product. A 2-second LCP would send visitors away before they saw a painting. No image-CDN that stamps a watermark on the hero image, no spinner while the page wakes a cold serverless function.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No third-party watermarks.&lt;/strong&gt; Image-CDN convenience — Cloudinary, imgix — isn't worth a logo in the corner of the hero. This is an artist's portfolio. The paintings deserve respect.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Real commerce, not a payment link.&lt;/strong&gt; Multi-currency (GBP / EUR / USD / AUD), international fulfillment for prints, originals shipped from the UK. A Stripe payment link in the Instagram bio wasn't going to cut it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No tracking, no cookie banner.&lt;/strong&gt; The site doesn't follow visitors. No analytics cookies, no third-party pixels, no consent popup to dismiss before you can see a painting. Purchase data goes only as far as it needs to: card details to Stripe, a shipping address — originals are shipped by us from the UK, prints via a print-on-demand provider for international orders. Nothing retained beyond what's needed to get the order out the door.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Indie budget.&lt;/strong&gt; One artist, no team, no investor. Anything more than ~£10/month total infrastructure is a recurring tax on the creative work. That ceiling shaped every hosting decision in the stack.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The stack, piece by piece
&lt;/h2&gt;

&lt;p&gt;The infrastructure splits cleanly across two providers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloudflare (free tier)
&lt;/h3&gt;

&lt;p&gt;Everything the visitor touches runs on Cloudflare. CDN and WAF sit in front of everything — Bot Fight Mode, rate limiting, R2 hotlink protection. Behind them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare Pages&lt;/strong&gt; hosts the Astro 6 storefront. Static by default, per-route SSR where needed (checkout callbacks, contact form, structured data feeds). The painting pages are pure HTML — the entire collection ships zero JS until the cart is opened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare R2&lt;/strong&gt; stores all images and process videos. Zero egress fees, served from a custom domain (&lt;code&gt;media.nadiapoe.co.uk&lt;/code&gt;). Videos are pre-encoded to four variants locally with ffmpeg (720p desktop, 480p mobile, JPEG poster, 64×64 thumb) and uploaded via wrangler. No URL transforms, no image-service quotas to exhaust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare D1&lt;/strong&gt; is the edge SQL database — one table, one purpose: like counts. The like button stores your choice in localStorage and increments a counter in D1. No cookies, no tracking, no consent popup. You see the count; nothing sees you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hetzner VPS — €3.29/mo
&lt;/h3&gt;

&lt;p&gt;Everything commerce-related runs on a single Hetzner CAX11: 2 vCPU ARM64, 4 GB RAM, 40 GB NVMe, 20 TB/month egress. On it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Medusa v2&lt;/strong&gt; — the commerce backend. Dev and prod as separate systemd services, both reverse-proxied by Caddy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; — orders, products, customers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis&lt;/strong&gt; — BullMQ event bus and workflow engine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Render, AWS, and Oracle Always Free each failed for a different specific reason. Hetzner just works.&lt;/p&gt;

&lt;p&gt;One gap in Medusa v2 worth knowing: there's no official Resend provider. Order confirmation emails skip the notification module entirely and call the Resend SDK directly from an &lt;code&gt;order.placed&lt;/code&gt; subscriber.&lt;/p&gt;

&lt;h3&gt;
  
  
  Third-party services
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Stripe&lt;/strong&gt; handles payments across four currencies (GBP / EUR / USD / AUD). Cloudflare injects &lt;code&gt;cf.country&lt;/code&gt; into a &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tag at the edge; the storefront reads it to pick the matching Medusa region and present prices in the local currency. User override persists in localStorage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resend&lt;/strong&gt; handles transactional email — 3,000 emails/month free, then $1/1,000.&lt;/p&gt;

&lt;h3&gt;
  
  
  The interactive layer
&lt;/h3&gt;

&lt;p&gt;Svelte islands handle the cart drawer, quantity controls, painting gallery, and region selector. Nano-stores (&lt;code&gt;cartStore&lt;/code&gt;, &lt;code&gt;cartUpdating&lt;/code&gt;, &lt;code&gt;regionStore&lt;/code&gt;) keep islands in sync without a framework router.&lt;/p&gt;

&lt;p&gt;The shop grid itself is static Astro HTML — no Svelte involved. The paintings are the product; a visitor should see the full collection immediately, not wait on an inventory check before anything renders. So every card defaults to "available," then a plain &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag calls the Medusa API in the background and patches &lt;code&gt;data-status&lt;/code&gt; on each card to flip sold originals to a faded state. No layout shift, no spinner, no framework overhead for a read-only grid.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One Astro 6 gotcha that cost an afternoon:&lt;/em&gt; server-side secrets must come from &lt;code&gt;import { env } from 'cloudflare:workers'&lt;/code&gt;. The old &lt;code&gt;Astro.locals.runtime.env&lt;/code&gt; was removed and &lt;code&gt;import.meta.env&lt;/code&gt; silently bakes &lt;code&gt;undefined&lt;/code&gt; for server-only vars. No build error, no runtime warning — just missing data in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually costs
&lt;/h2&gt;

&lt;p&gt;Backend hosting: &lt;strong&gt;€3.29/mo&lt;/strong&gt; — both staging and production Medusa instances, PostgreSQL, Redis, Caddy, automated nightly backups to Cloudflare R2. Frontend, CDN, edge SQL, object storage, WAF, analytics: free tier. The metered costs are Stripe (1.5–2.9% per transaction — unavoidable regardless of platform, but at least there's no &lt;em&gt;extra&lt;/em&gt; platform cut on top) and Resend (3,000 emails/month free, then $1/1,000).&lt;/p&gt;

&lt;p&gt;Compare that to a typical marketplace taking 6–7% on a £150 original watercolour — that's £9–10 per sale, forever, to infrastructure you don't own. At even modest volume the self-hosted setup pays for itself inside the first month.&lt;/p&gt;

&lt;p&gt;Will this survive a traffic spike? Honestly, no idea — this shop has never been Slashdotted, and a 2 vCPU ARM box with 4 GB RAM is not going to win any load test. But if it ever buckles under the weight of people trying to buy original watercolours, upgrading the VPS will be the easiest problem on the list that day.&lt;/p&gt;

&lt;h2&gt;
  
  
  One snippet worth stealing
&lt;/h2&gt;

&lt;p&gt;This pattern only works because the HTML is served through a Cloudflare Worker — but if you're already on Cloudflare Pages, you have that for free.&lt;/p&gt;

&lt;p&gt;The full CSP is set per-request with a fresh nonce, injected into every &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag at the edge. No build-time hash dance, no list of inline script hashes to maintain. From &lt;code&gt;client/src/middleware.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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineMiddleware&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="nx"&gt;context&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="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;response&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;next&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;HTMLRewriter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&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;response&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&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;response&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonceBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&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;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;nonceBytes&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;rewritten&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HTMLRewriter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&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;element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nonce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonce&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="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&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;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rewritten&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Security-Policy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;buildCsp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rewritten&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rewritten&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&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;Cloudflare's own bot-fight challenge scripts get whitelisted automatically — they read the nonce from the CSP header and stamp themselves with it. On a static-only setup you're stuck with hashes, and those break the moment Cloudflare rotates a challenge token. The Worker approach sidesteps that entirely.&lt;/p&gt;

&lt;p&gt;The site is &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt; if you want to see the result.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>svelte</category>
      <category>cloudflare</category>
      <category>medusa</category>
    </item>
  </channel>
</rss>
