<?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: Onkar Deokate</title>
    <description>The latest articles on DEV Community by Onkar Deokate (@rakno).</description>
    <link>https://dev.to/rakno</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%2F3903579%2F2780481a-5e16-40ac-8840-126059da6cde.png</url>
      <title>DEV Community: Onkar Deokate</title>
      <link>https://dev.to/rakno</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rakno"/>
    <language>en</language>
    <item>
      <title>The car wash that comes to the car</title>
      <dc:creator>Onkar Deokate</dc:creator>
      <pubDate>Sun, 11 Oct 2026 03:30:00 +0000</pubDate>
      <link>https://dev.to/rakno/the-car-wash-that-comes-to-the-car-1dl9</link>
      <guid>https://dev.to/rakno/the-car-wash-that-comes-to-the-car-1dl9</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Yf-iWzAQtmY" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Here's a quiet truth about parking: the car just sits there. Two hours, four hours, a whole working day, doing nothing.&lt;/p&gt;

&lt;p&gt;What if, while it sits, someone washes it?&lt;/p&gt;

&lt;p&gt;That's the last role in ParkEase. A driver books a wash for the car they've just parked, and a local car washer comes to it. Yesterday you saw how ParkEase decides which washer gets the job when three of them tap at once. Today, the washer's side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things you can do as a car washer (the video is thirty seconds)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Set your own menu.&lt;/strong&gt;&lt;br&gt;
Basic exterior, premium, full detailing: switch on the washes you offer, and set a car price and a bike price for each, plus how long it takes. Your menu, your prices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Go online, and jobs come to you.&lt;/strong&gt;&lt;br&gt;
Flip one switch and nearby wash jobs appear, each with what you'll earn, how far away the car is, and how long the wash takes. A countdown shows how long the offer stays open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Before and after photos.&lt;/strong&gt;&lt;br&gt;
Accept a job and it's yours. A &lt;em&gt;before&lt;/em&gt; photo unlocks &lt;em&gt;Start washing&lt;/em&gt;; an &lt;em&gt;after&lt;/em&gt; photo finishes the job. No photo, no wash, so a driver always has proof of the car's condition on both sides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Every wash, line by line.&lt;/strong&gt;&lt;br&gt;
Your earnings show each completed wash: the service price, the ParkEase fee, and what you earned. If a wash was cancelled after you accepted it, the take-back is listed too, not hidden in a total.&lt;/p&gt;

&lt;p&gt;That's the whole washer app today.&lt;/p&gt;

&lt;h2&gt;
  
  
  And that's the cast
&lt;/h2&gt;

&lt;p&gt;Four roles, four apps in one: the &lt;strong&gt;owner&lt;/strong&gt; with an empty parking spot, the &lt;strong&gt;driver&lt;/strong&gt; circling the block, the &lt;strong&gt;valet&lt;/strong&gt; who parks for you, and the &lt;strong&gt;washer&lt;/strong&gt; who cleans the car while it waits. Every one of them was filmed from the real app, not mocked up in a design tool.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; everything so far ends with "what you earned". But earned isn't &lt;em&gt;paid&lt;/em&gt;. The next update is the moment money actually leaves ParkEase and lands in an owner's bank account, and every way that can go wrong in between. Follow the series. Launch is close.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ParkEase is a peer-to-peer parking marketplace for India, launching soon on Android.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Music: ende.app (CC BY 4.0)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>startup</category>
      <category>android</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Three washers. One job. Zero locks.</title>
      <dc:creator>Onkar Deokate</dc:creator>
      <pubDate>Sat, 10 Oct 2026 03:30:00 +0000</pubDate>
      <link>https://dev.to/rakno/three-washers-one-job-zero-locks-1khl</link>
      <guid>https://dev.to/rakno/three-washers-one-job-zero-locks-1khl</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3mrafuk0r60ewn6c9ah6.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3mrafuk0r60ewn6c9ah6.gif" alt="One wash job, three accepts: the WHERE clause decides, and a CHECK constraint guards the photos." width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Iv9fOmssZlI" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;A driver parks, opens ParkEase and books a car wash. The job goes out to up to three nearby washers at once, because the first one might be on a break and the driver shouldn't wait.&lt;/p&gt;

&lt;p&gt;All three see it. All three tap &lt;strong&gt;Accept&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Exactly one of them can have it. The other two need to hear "this one's gone, more coming," not an error page, not a silent spinner, and &lt;em&gt;definitely&lt;/em&gt; not a second washer turning up at the same car.&lt;/p&gt;

&lt;p&gt;Yesterday I promised there isn't a single lock in the code. There isn't. Here's what there is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The WHERE clause is the lock
&lt;/h2&gt;

&lt;p&gt;The usual answers are &lt;code&gt;SELECT … FOR UPDATE&lt;/code&gt;, an advisory lock, or a retry loop. ParkEase uses none of them. The accept is one &lt;code&gt;UPDATE&lt;/code&gt;, and its &lt;code&gt;WHERE&lt;/code&gt; clause only matches a job that is &lt;em&gt;still on offer&lt;/em&gt; and &lt;em&gt;still has no washer&lt;/em&gt;:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;washJobs&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="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;washJobs&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;isNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;washJobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;washerUserId&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;returning&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;accepted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;updated&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WashJobAlreadyTakenError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres serialises updates to the same row. The first accept flips the job to &lt;code&gt;accepted&lt;/code&gt;. By the time the second and third get their turn, the row no longer matches, so they update &lt;strong&gt;zero rows&lt;/strong&gt;. Zero rows becomes a &lt;code&gt;409&lt;/code&gt; with copy a washer can read: &lt;em&gt;This job was taken by another partner. More jobs coming!&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's the same move as the booking system earlier in this series: don't arrange for the race not to happen. Let the database answer it.&lt;/p&gt;

&lt;p&gt;One more thing is deliberately &lt;em&gt;missing&lt;/em&gt; from that transaction: the payment gateway. Creating the payment order inside the accept would mean the two losers each left an orphan order at the gateway that no webhook would ever match. So the order is created later, by the driver's own pay action.&lt;/p&gt;

&lt;h2&gt;
  
  
  No photo, no wash. The database agrees.
&lt;/h2&gt;

&lt;p&gt;A wash can't start without a &lt;em&gt;before&lt;/em&gt; photo, and can't finish without an &lt;em&gt;after&lt;/em&gt; photo. The app greys out the buttons, but the app isn't the guard. The row is:&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;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;wash_jobs_photo_gate_check&lt;/span&gt;
&lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'washing'&lt;/span&gt;   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;before_photo_id&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="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before_photo_id&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;after_photo_id&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="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No code path, admin tool or future bug can produce a completed wash with no evidence it happened. The command checks the same rule first, purely to turn a constraint name into a sentence a washer can act on:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;start_washing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;beforePhotoId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BeforePhotoRequiredError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Proven over real HTTP
&lt;/h2&gt;

&lt;p&gt;The integration suite runs the real API against a real Postgres in a throwaway container. Three washers accept the same job and the statuses come back &lt;code&gt;[200, 409, 409]&lt;/code&gt;. A retried accept with the same idempotency key replays the original answer instead of racing itself. The photo gates refuse in both directions. &lt;strong&gt;84 tests, all passing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One honest detail: the three accepts in that test run one after another, not truly in parallel. That's on purpose. The test harness shares its "who am I" state between requests, so a parallel version would pass for the wrong reason. The race being proven lives in the database's &lt;code&gt;WHERE&lt;/code&gt; clause, and each accept still meets a row the previous one already moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself before starting
&lt;/h2&gt;

&lt;p&gt;Every time I've reached for a lock, the database already had a better one: a constraint, an exclusion, a &lt;code&gt;WHERE&lt;/code&gt;. Write the invariant where nothing can route around it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tomorrow:&lt;/strong&gt; the last role video. What a car washer actually does in ParkEase: their own menu, jobs that come to them, and the two photos that bookend every wash. Follow the series.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ParkEase is a peer-to-peer parking marketplace for India, built solo and launching soon on Android.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Code: &lt;a href="https://github.com/Deonkar/parkease" rel="noopener noreferrer"&gt;https://github.com/Deonkar/parkease&lt;/a&gt; · Music: ende.app (CC BY 4.0)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>nestjs</category>
      <category>typescript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Hand over the keys. Get the car back.</title>
      <dc:creator>Onkar Deokate</dc:creator>
      <pubDate>Fri, 09 Oct 2026 03:30:00 +0000</pubDate>
      <link>https://dev.to/rakno/hand-over-the-keys-get-the-car-back-2gij</link>
      <guid>https://dev.to/rakno/hand-over-the-keys-get-the-car-back-2gij</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/0gKVcDvU_GA" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Some drivers don't want to find parking at all. They want to pull up at the door, hand over the keys, and walk into the restaurant. In most Indian cities that's a hotel or wedding-hall luxury.&lt;/p&gt;

&lt;p&gt;ParkEase makes it a job anyone verified can do. Yesterday you saw what happens when a valet's phone loses signal. Today: what the valet actually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things you can do as a valet (the video is thirty seconds)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. See the job before you touch the car.&lt;/strong&gt;&lt;br&gt;
Every job shows the pickup address and exactly what you'll earn for it. No guessing, no negotiating at the kerb.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. One tap per step.&lt;/strong&gt;&lt;br&gt;
I'm on the way. I've arrived. Start parking. Each step is one big button, in order, so a valet with a driver waiting never hunts through a menu.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. No photo, no confirm.&lt;/strong&gt;&lt;br&gt;
Before a valet can confirm a car is parked, they take a photo of it, parked. The &lt;em&gt;Confirm parked&lt;/em&gt; button stays locked until that photo is attached. If anything is ever disputed, there's evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. See what you've earned.&lt;/strong&gt;&lt;br&gt;
Every job credited, straight from the same ledger the rest of ParkEase runs on.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A note on the video: the clips come from the web preview. A valet goes online with background location, which only the Android app has. So the preview honestly says "You are offline" at the top of every screen, and you won't see a job offer arrive. The job screens, the photo rule and the earnings are the real app.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule nobody notices
&lt;/h2&gt;

&lt;p&gt;Look at step 3 again. The photo isn't optional, and it isn't a suggestion. It's a lock on the button. That's a pattern you'll see again tomorrow, taken one step further: down into the database itself.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tomorrow:&lt;/strong&gt; the last role, and a race. A driver books a car wash. The job goes out to three nearby washers at once. All three tap &lt;em&gt;Accept&lt;/em&gt; in the same second. Only one can win, and there isn't a single lock in the code. Tomorrow, how. Follow the series.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ParkEase is a peer-to-peer parking marketplace for India, launching soon on Android.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Music: ende.app (CC BY 4.0)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>startup</category>
      <category>android</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The signal dropped. Mid-trip. With your car.</title>
      <dc:creator>Onkar Deokate</dc:creator>
      <pubDate>Thu, 08 Oct 2026 03:30:00 +0000</pubDate>
      <link>https://dev.to/rakno/the-signal-dropped-mid-trip-with-your-car-4dhi</link>
      <guid>https://dev.to/rakno/the-signal-dropped-mid-trip-with-your-car-4dhi</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxx4enur6mt7kqho8h9fg.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxx4enur6mt7kqho8h9fg.gif" alt="The valet's location queue: saved first, sent second; offline waits, refused is dropped; ten tests." width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/l9PbOy-gCLE" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;You've handed your car keys to a stranger. That's what a valet is. They drive off to park it, and the only thing between you and mild panic is a dot on a map, moving.&lt;/p&gt;

&lt;p&gt;Then the valet drives into a basement. Or under a flyover. Or into one of the many parts of Bangalore where 4G just... isn't.&lt;/p&gt;

&lt;p&gt;The dot stops.&lt;/p&gt;

&lt;p&gt;Yesterday I asked what happens next. Here's the answer, and it's the most careful forty lines in the whole app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious version loses data
&lt;/h2&gt;

&lt;p&gt;The valet app reports a location fix every few seconds, from a background task. The obvious code is: on each fix, send it to the server.&lt;/p&gt;

&lt;p&gt;That works right up until the send fails. The background task runs in a headless context where the connection may not even be up. A fire-and-forget send from there doesn't throw anywhere you can see. The fix is just &lt;em&gt;gone&lt;/em&gt;, and the route on the driver's map has a hole in it exactly where they were most worried.&lt;/p&gt;

&lt;h2&gt;
  
  
  Saved first, sent second
&lt;/h2&gt;

&lt;p&gt;So nothing is ever sent before it's stored. Every fix goes into a small on-phone queue, and &lt;em&gt;then&lt;/em&gt; the queue tries to drain:&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="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fix&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;queued&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ordered&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;read&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="nx"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;maxFixes&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;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queued&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It still sends immediately. My first version enqueued and &lt;em&gt;waited&lt;/em&gt;, which meant nothing reached the server between going online and going offline, and the valet's "last seen" went stale within a minute. The queue is for when a send fails, not a substitute for trying.&lt;/p&gt;

&lt;p&gt;The queue has a ceiling, because a phone in a long tunnel shouldn't fill its storage with coordinates:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Roughly 15 minutes at the configured interval. */&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;MAX_QUEUED_FIXES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Past that, the oldest fixes go and the newest always stay. Where the car is &lt;em&gt;now&lt;/em&gt; matters more than where it was a quarter of an hour ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline waits. Refused is dropped.
&lt;/h2&gt;

&lt;p&gt;Here's the subtle part. A send can fail for two very different reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offline.&lt;/strong&gt; Worth trying again. Stop here, keep everything, in order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rejected.&lt;/strong&gt; The server said no: say, a fix for a job this valet isn't assigned to. That will be a no &lt;em&gt;forever&lt;/em&gt;. Retrying it would block every good fix queued behind it.&lt;/li&gt;
&lt;/ul&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// A refused fix is consumed; an unsendable one halts the drain so the&lt;/span&gt;
&lt;span class="c1"&gt;// rest keep their order for the next attempt.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queued&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when the signal comes back, the backlog replays oldest first, so the driver's map redraws the route in the order it was actually driven:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Oldest first, so a drain replays the route in the order it was travelled. */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fixes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;LocationFix&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;LocationFix&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;fixes&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&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="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordedAt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordedAt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more things the queue survives: two drains racing each other (only one runs at a time, or the same backlog gets sent twice), and a corrupted stored value (the queue starts empty instead of crashing a background task nobody is watching).&lt;/p&gt;

&lt;h2&gt;
  
  
  Ten tests
&lt;/h2&gt;

&lt;p&gt;Every one of those decisions has a test: send on capture, hold on failure, oldest first, the bound, refused-versus-offline, stop at the first unsendable fix, a corrupt store, a single malformed entry. Ten tests, all passing, and none of them need a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself before starting
&lt;/h2&gt;

&lt;p&gt;"Offline" isn't an error to handle. It's a &lt;em&gt;state&lt;/em&gt; the app spends real time in. Design for it first, and the happy path falls out for free.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tomorrow:&lt;/strong&gt; you've seen what happens in the tunnel. Tomorrow, the valet's side of the screen: taking a job, every step one tap, and the photo of the parked car that has to exist before the job can be confirmed. Follow the series.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ParkEase is a peer-to-peer parking marketplace for India, built solo and launching soon on Android.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Code: &lt;a href="https://github.com/Deonkar/parkease" rel="noopener noreferrer"&gt;https://github.com/Deonkar/parkease&lt;/a&gt; · Music: ende.app (CC BY 4.0)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>typescript</category>
      <category>android</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Stop circling the block</title>
      <dc:creator>Onkar Deokate</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:30:00 +0000</pubDate>
      <link>https://dev.to/rakno/stop-circling-the-block-1afn</link>
      <guid>https://dev.to/rakno/stop-circling-the-block-1afn</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/JzmtjPphnu4" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;You know the loop. Indicator on, crawl past the same shuttered shop, squint at a gap that turns out to be a driveway, go round again. Ten minutes for a space that was probably free the whole time, just behind a gate you didn't know you could use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ParkEase&lt;/strong&gt; opens those gates. People with an empty driveway, a basement slot or a society visitor bay rent it out by the hour, and drivers book it before they arrive. Two days ago you met the owners. Yesterday you watched the map break. Today it works, and it's yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five things you can do as a driver (the whole video is thirty seconds)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. See every free spot around you.&lt;/strong&gt;&lt;br&gt;
Open the app and the map fills in with real parking spaces near you, each one with its price on the pin. No searching, no guessing which lane has room.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Filter for what you need.&lt;/strong&gt;&lt;br&gt;
Car or bike. Under ₹50 an hour. Covered, CCTV, EV charging, wheelchair access. The map keeps only the spots that fit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. See the price before you commit, and why.&lt;/strong&gt;&lt;br&gt;
Tap a spot and you get today's hourly price, how many slots are free right now, and, when demand is high, a plain label saying so: &lt;em&gt;1.25x moderate demand&lt;/em&gt;. No surprise at checkout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Every rupee, listed. Then your spot is held.&lt;/strong&gt;&lt;br&gt;
Before you pay you see the whole bill: base price, the demand surcharge, GST, the total. You even see what the owner earns. Tap &lt;em&gt;Hold this spot&lt;/em&gt; and it's yours for ten minutes while you pay. Nobody can take it from under you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Walk up with a QR code.&lt;/strong&gt;&lt;br&gt;
Booked. Your phone shows a QR code. The owner scans it at the gate, you park, done.&lt;/p&gt;

&lt;p&gt;That's the whole driver app today: find, check, hold, park.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody sees
&lt;/h2&gt;

&lt;p&gt;Step 4 has a promise hidden in it: &lt;em&gt;nobody can take it from under you&lt;/em&gt;. Two drivers, one last slot, both tapping &lt;em&gt;Hold&lt;/em&gt; in the same second. Exactly one of them gets it, every time, and that's enforced by the database itself (I wrote that one up &lt;a href="https://dev.to/rakno/postgres-wont-let-me-double-book-a-parking-slot-exclusion-constraints-in-practice-4aa5"&gt;earlier in the series&lt;/a&gt;).&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tomorrow:&lt;/strong&gt; a new role, and a harder problem. Some drivers don't want to park at all. They want to hand over the keys at the door. That means a &lt;em&gt;valet&lt;/em&gt;: a stranger driving your car, with the app reporting where it is and a photo when it is parked. What happens when the valet's phone loses signal halfway? Tomorrow, the engineering. Follow the series.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ParkEase is a peer-to-peer parking marketplace for India, launching soon on Android.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Map: © OpenStreetMap contributors · Music: ende.app (CC BY 4.0)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>startup</category>
      <category>android</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The map was blank. Every test was green.</title>
      <dc:creator>Onkar Deokate</dc:creator>
      <pubDate>Tue, 06 Oct 2026 03:30:00 +0000</pubDate>
      <link>https://dev.to/rakno/the-map-was-blank-every-test-was-green-4e44</link>
      <guid>https://dev.to/rakno/the-map-was-blank-every-test-was-green-4e44</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fic1z7140olnk1a5c9tqs.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fic1z7140olnk1a5c9tqs.gif" alt="The driver map: blank, then every parking spot piled into one corner, then every spot in place." width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/dFxDWZCSR_A" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The most important screen in a parking app is the map. It's the first thing a driver sees, and the only reason they opened the app.&lt;/p&gt;

&lt;p&gt;On the web build of ParkEase, that map was blank.&lt;/p&gt;

&lt;p&gt;No crash. No red error box. No failing test. Just an empty rectangle where Bangalore should have been, sitting quietly above a perfectly working list of parking spots. Every test passed, because no test looked at the map.&lt;/p&gt;

&lt;p&gt;Yesterday I promised you the story. Here it is: three bugs, stacked so neatly that fixing one only revealed the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug one: the worker that was a web page
&lt;/h2&gt;

&lt;p&gt;MapLibre, the open-source map renderer ParkEase uses, does its heavy lifting in a Web Worker. To find that worker's file, MapLibre 6 builds a URL relative to its own module.&lt;/p&gt;

&lt;p&gt;Under Expo's web bundler there is no "own module". There's one big bundle. So the URL pointed at a file that didn't exist, and the dev server did what dev servers do with unknown paths: it served the app's HTML page.&lt;/p&gt;

&lt;p&gt;MapLibre asked for JavaScript, got HTML, and the worker died before processing a single tile. Nothing on screen said so.&lt;/p&gt;

&lt;p&gt;The fix is to tell MapLibre exactly where its worker lives, and to make sure it's really there:&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;MAPLIBRE_WORKER_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/maplibre/maplibre-gl-worker.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="c1"&gt;// Before any Map is created: MapLibre spawns its workers on the first one.&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;WebMapModule&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setWorkerUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MAPLIBRE_WORKER_URL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;postinstall&lt;/code&gt; script copies the worker, and every chunk it imports, into the folder the web build serves. It follows the worker's own imports instead of a hard-coded file list, so a future MapLibre release that splits out another chunk gets copied too. If the copy fails, the install fails, loudly, and names the web map. A silent copy failure is how this whole story started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: the box with no height
&lt;/h2&gt;

&lt;p&gt;Workers fixed. Tiles loading. Map... still blank.&lt;/p&gt;

&lt;p&gt;The CSS ParkMap injects (the app doesn't load MapLibre's own stylesheet) had one extra rule: &lt;code&gt;overflow: hidden&lt;/code&gt; on the canvas container. That container only holds absolutely positioned children, so it has &lt;strong&gt;zero height&lt;/strong&gt;. Clip a zero-height box and you clip everything inside it: the canvas and every marker.&lt;/p&gt;

&lt;p&gt;The fix was deleting a line. The test that keeps it deleted:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;never clips the map canvas container&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &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;body&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MAPLIBRE_CSS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.maplibregl-canvas-container&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;expect&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="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/overflow&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&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;h2&gt;
  
  
  Bug three: every spot in one corner
&lt;/h2&gt;

&lt;p&gt;This is my favourite, because the map &lt;em&gt;came back&lt;/em&gt;. Streets, parks, the blue dot where you're standing. And in the top-left corner, one lonely price tag: ₹20.&lt;/p&gt;

&lt;p&gt;I counted the marker elements: twenty, all with the same coordinates.&lt;/p&gt;

&lt;p&gt;Here's why. MapLibre places each marker by writing a &lt;code&gt;transform&lt;/code&gt; onto the marker element, something like &lt;code&gt;translate(412px, 318px)&lt;/code&gt;. Our entrance animation, a gentle scale-and-rise as spots appear, &lt;em&gt;also&lt;/em&gt; animated &lt;code&gt;transform&lt;/code&gt;. A CSS animation beats an inline style, so every marker's position was overwritten with &lt;code&gt;scale(1) translateY(0)&lt;/code&gt;. Which, as positions go, is the top-left corner.&lt;/p&gt;

&lt;p&gt;The fix keeps the exact same animation and moves it onto the individual &lt;code&gt;scale&lt;/code&gt; and &lt;code&gt;translate&lt;/code&gt; properties. Those compose &lt;em&gt;with&lt;/em&gt; &lt;code&gt;transform&lt;/code&gt; instead of replacing it:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="s2"&gt;`@keyframes parkease-marker-in{from{opacity:0;scale:0.6;translate:0 6px}to{opacity:1;scale:1;translate:0 0}}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the test that will catch the next person (probably me) who reaches for &lt;code&gt;transform&lt;/code&gt;:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;never animates transform on the marker element MapLibre positions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;keyframes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MAPLIBRE_CSS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;parkease-marker-in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;^|&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;{;&lt;/span&gt;&lt;span class="se"&gt;])\s&lt;/span&gt;&lt;span class="sr"&gt;*transform&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&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;Ten tests now guard the map: three on the CSS, four on how the map starts (including that it shows a real "map unavailable" state with the reason, instead of a blank box, when the worker can't be set up), and three on the worker copy.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A note on the video: the "before" frames were reproduced on the fixed build by putting the old behaviour back: the worker request answered with the HTML page, and the old CSS rules re-injected. The broken states are real; they're just recreated rather than recorded at the time.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself before starting
&lt;/h2&gt;

&lt;p&gt;A test suite proves the things you thought to check. A blank screen isn't an error, so nothing failed. Now the rule is simple: if a screen can be empty, the empty state has to say &lt;em&gt;why&lt;/em&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tomorrow:&lt;/strong&gt; you've seen the map break. Tomorrow you see what it's for. Open the app as a driver, find a free spot three streets away, see exactly what it costs and why, and walk up to the gate with a QR code in your hand. Five steps. Thirty seconds. Follow the series so you don't miss it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ParkEase is a peer-to-peer parking marketplace for India, built solo and launching soon on Android.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Code: &lt;a href="https://github.com/Deonkar/parkease" rel="noopener noreferrer"&gt;https://github.com/Deonkar/parkease&lt;/a&gt; · Map: © OpenStreetMap contributors · Music: ende.app (CC BY 4.0)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Your empty parking spot just got a job</title>
      <dc:creator>Onkar Deokate</dc:creator>
      <pubDate>Fri, 02 Oct 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/rakno/your-empty-parking-spot-just-got-a-job-4k4e</link>
      <guid>https://dev.to/rakno/your-empty-parking-spot-just-got-a-job-4k4e</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/wf_wQYXr0nk" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Somewhere in Bangalore right now there's a driveway with nothing in it. A basement slot that sits empty from nine to six. A space someone pays for and never uses.&lt;/p&gt;

&lt;p&gt;And three streets away, a driver is circling the same block for the fourth time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ParkEase&lt;/strong&gt; puts those two people together. Yesterday I showed you the ledger underneath. Today, the part that matters: what it's like to be the owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five things you can do (the whole video is under thirty seconds)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. List your space in five quick steps.&lt;/strong&gt;&lt;br&gt;
Where it is, what it's called, how many cars and bikes fit, your price, when it's available. Submit, and your listing waits for approval before it goes live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Set your own price.&lt;/strong&gt;&lt;br&gt;
Per hour, per slot, separately for cars and for two-wheelers. Your spot, your number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Check drivers in with one scan.&lt;/strong&gt;&lt;br&gt;
When a driver arrives they show a QR code. You scan it, and your phone confirms who it is, their vehicle number and their booked time. No paper, no guessing, no "I booked, I promise."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. See every booking, line by line.&lt;/strong&gt;&lt;br&gt;
Every booking shows three lines: the price, the ParkEase fee, and exactly what you earned. No mystery deductions. (Yesterday's post is the story of why that line is harder to get right than it looks.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Watch it add up.&lt;/strong&gt;&lt;br&gt;
Today's earnings, this week's, with a chart that fills in as the week goes.&lt;/p&gt;

&lt;p&gt;That's the whole owner app today. It's small on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm not telling you yet
&lt;/h2&gt;

&lt;p&gt;Everything above happens on the owner's phone. The other half of ParkEase is a driver looking at a map of free spots nearby.&lt;/p&gt;

&lt;p&gt;For a while, on the web build, that map was empty. No error on screen. Every test green. Just a blank box where Bangalore should be. And when the map finally came back, every parking spot on it was piled into the top-left corner.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tomorrow:&lt;/strong&gt; we cross to the other side of the gate. The driver's map: why it was blank, and the single CSS animation that stacked twenty-one parking spots in one corner. Follow the series. Launch is closer than you think.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ParkEase is a peer-to-peer parking marketplace for India, launching soon on Android.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Music: ende.app (CC BY 4.0)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>startup</category>
      <category>android</category>
      <category>productivity</category>
    </item>
    <item>
      <title>One booking. Three answers. Only one was true.</title>
      <dc:creator>Onkar Deokate</dc:creator>
      <pubDate>Thu, 01 Oct 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/rakno/one-booking-three-answers-only-one-was-true-2n9i</link>
      <guid>https://dev.to/rakno/one-booking-three-answers-only-one-was-true-2n9i</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4o1aytpo0lx9z270l52z.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4o1aytpo0lx9z270l52z.gif" alt="One booking, three answers: ₹82.47, ₹51.00, ₹42.50. Two get struck through." width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/4eCw2jclaYM" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;A parking space owner opens their app and sees &lt;strong&gt;₹82.47&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;They tap Earnings. It says &lt;strong&gt;₹51.00&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Payday comes. &lt;strong&gt;₹42.50&lt;/strong&gt; lands.&lt;/p&gt;

&lt;p&gt;Same booking. Same owner. Three different numbers, and every one of them came from code that looked correct.&lt;/p&gt;

&lt;p&gt;That was version one of ParkEase, the peer-to-peer parking marketplace I'm building for India. People with an empty driveway or basement slot list it; drivers find it on a map and book it by the hour. And we're close to launch. Close enough that "which number is real?" had to become a question the app can't even ask.&lt;/p&gt;

&lt;p&gt;This is how.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three careful people, three wrong answers
&lt;/h2&gt;

&lt;p&gt;Nobody wrote a bug on purpose. Three parts of the app each answered "how much did this owner earn?":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;dashboard&lt;/strong&gt; took 85% of the booking total.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;earnings screen&lt;/strong&gt; summed the owner's account in the ledger.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;payout job&lt;/strong&gt; subtracted a recomputed fee from the base price.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each formula is locally reasonable. Put a surged booking through all three and they split three ways. The dashboard counted surge as the owner's money (it isn't), and the payout job lost the surge allocation entirely.&lt;/p&gt;

&lt;p&gt;Only one of them was reading the truth: the ledger. So the fix wasn't a fourth formula. It was deleting two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every figure is one query
&lt;/h2&gt;

&lt;p&gt;Now there is one question, asked one way. Owed, today, this month, last month: all four are the same function over the ledger, scoped to a different period:&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="nf"&gt;forOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OwnerDashboardData&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&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;tx&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="c1"&gt;// …&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;owed&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;balance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;movement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tx&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;today&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;balance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;movement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;periodBound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;today&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;tx&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;month&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;balance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;movement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;periodBound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;month&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;tx&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;lastMonth&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;balance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;movement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;lastMonthToDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&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;isolationLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;repeatable read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accessMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;read only&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;movement()&lt;/code&gt; is nothing clever. It's a sum over the owner's side of a double-entry ledger, in integer paise, so there is no floating point anywhere near money:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CREDITS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;`coalesce(sum(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ledgerEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amountPaise&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)
  filter (where &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ledgerEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; = 'credit'), 0)::text`&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;DEBITS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;`coalesce(sum(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ledgerEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amountPaise&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)
  filter (where &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ledgerEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; = 'debit'), 0)::text`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;movement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;bound&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SQL&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Reader&lt;/span&gt; &lt;span class="o"&gt;=&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;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Movement&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;row&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;reader&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;creditsPaise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CREDITS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;debitsPaise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DEBITS&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ledgerEntries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;innerJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bookings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bookings&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="nx"&gt;ledgerEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bookingId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;innerJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spaces&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spaces&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="nx"&gt;bookings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spaceId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ownerScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;bound&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;creditsPaise&lt;/span&gt; &lt;span class="o"&gt;=&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;row&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;creditsPaise&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;debitsPaise&lt;/span&gt; &lt;span class="o"&gt;=&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;row&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;debitsPaise&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;creditsPaise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;debitsPaise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;netPaise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;net&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;debitsPaise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;creditsPaise&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;h2&gt;
  
  
  The detail that's easy to miss
&lt;/h2&gt;

&lt;p&gt;Look at the last line of that transaction: &lt;code&gt;repeatable read&lt;/code&gt;, &lt;code&gt;read only&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those four &lt;code&gt;movement()&lt;/code&gt; calls are four separate &lt;code&gt;SELECT&lt;/code&gt;s. Without one snapshot, a booking that settles between the "today" read and the "month" read could make &lt;strong&gt;today bigger than this month&lt;/strong&gt; for one page load. Rare? Yes. The kind of thing an owner screenshots and sends to support? Also yes. One read-only snapshot means all four numbers see the same instant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surge never touches the owner's line
&lt;/h2&gt;

&lt;p&gt;The platform takes one fee: 15% of the base price plus all of the surge. So an owner's earnings are always base minus 15%, whatever demand did that evening. The rate lives in exactly one dated table:&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;PLATFORM_COMMISSION_RATE_HISTORY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;DatedRate&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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;toRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;effectiveFrom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-01-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Launch rate. 15% of base, charged once, deducted from the owner payout.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Don't trust me. Trust the test.
&lt;/h2&gt;

&lt;p&gt;"They use the same function" is a claim. This test turns it into a fact. It goes through the real HTTP stack, against a real Postgres in a throwaway container, books twice, and asks both screens:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dashboard and earnings agree on owner earnings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;spaceId&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;seedSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;12.9345&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;77.6266&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;carSlots&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;bookConfirmed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spaceId&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;bookConfirmed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&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;dashboard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;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;/owner/dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;netPaise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="nl"&gt;owedPaise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;earnings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;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;/owner/earnings?period=month&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;netPaise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;bookings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dashboard&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;month&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;netPaise&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;earnings&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;netPaise&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dashboard&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;owedPaise&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;earnings&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;netPaise&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;earnings&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;bookings&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dashboard&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;statement&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All 10 tests in that file pass. One of them pins the other half of the rule: a surged booking shows the owner base, fee and net, with no surge, no GST and no booking total.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson I paid for
&lt;/h2&gt;

&lt;p&gt;If a user can put two numbers side by side, they must come from &lt;strong&gt;the same function&lt;/strong&gt;. Not the same formula, copied carefully. The same function.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tomorrow:&lt;/strong&gt; you've seen the machinery. Tomorrow you see what it's for: what a parking space owner actually does in ParkEase, from an empty driveway to a driver pulling up at the gate and the moment their phone says &lt;em&gt;checked in&lt;/em&gt;. Five things. Under thirty seconds. Follow the series so you don't miss it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ParkEase is a peer-to-peer parking marketplace for India, built solo and launching soon on Android.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Code: &lt;a href="https://github.com/Deonkar/parkease" rel="noopener noreferrer"&gt;https://github.com/Deonkar/parkease&lt;/a&gt; · Music: ende.app (CC BY 4.0)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>typescript</category>
      <category>nestjs</category>
      <category>showdev</category>
    </item>
    <item>
      <title>72 commits, 42 decisions, 6 hidden bugs: building a React Native partner app with Claude as a team, not a tool</title>
      <dc:creator>Onkar Deokate</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/rakno/72-commits-42-decisions-6-hidden-bugs-building-a-react-native-partner-app-with-claude-as-a-team-11mf</link>
      <guid>https://dev.to/rakno/72-commits-42-decisions-6-hidden-bugs-building-a-react-native-partner-app-with-claude-as-a-team-11mf</guid>
      <description>&lt;p&gt;I'm building &lt;strong&gt;ParkEase&lt;/strong&gt;, a peer-to-peer parking marketplace for India: drivers, space owners, valets, car-wash partners and admins, spread across a NestJS API, a worker, an Expo React Native app, an admin panel and a marketing site. Task 14 was the &lt;strong&gt;car-wash partner app&lt;/strong&gt;. That's the screen a washer lives in all day: receive offers, take before and after photos, edit a price menu, check earnings.&lt;/p&gt;

&lt;p&gt;I built it with Claude Code, and "built with AI" undersells how it actually worked. The task ended as &lt;strong&gt;72 commits, about 22,800 added lines across 185 files, 1,013 mobile tests, 397 integration tests against real Postgres, 42 recorded decisions, and six defects&lt;/strong&gt; that had been sitting in already-merged code. It also took far longer than it should have, and some of the mistakes were the AI's own. Here's all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: &lt;code&gt;/flow&lt;/code&gt;, gates, and a paper trail
&lt;/h2&gt;

&lt;p&gt;The core of my workflow is a skill I call &lt;strong&gt;&lt;code&gt;/flow&lt;/code&gt;&lt;/strong&gt;. It isn't a prompt. It's a routing layer that knows my project: the architecture decision records (ADRs), the coding rules, the task files, and which of about 80 skills and 25 specialist subagents apply at each phase. Every phase has a gate:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Gate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Think&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;brainstorming&lt;/code&gt;: no code until I approve a design&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direction (UI only)&lt;/td&gt;
&lt;td&gt;2–3 &lt;strong&gt;rendered&lt;/strong&gt; visual directions; I pick&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plan&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;writing-plans&lt;/code&gt;: bite-sized TDD tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;test-driven-development&lt;/code&gt;: see it fail first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review&lt;/td&gt;
&lt;td&gt;a &lt;strong&gt;stack&lt;/strong&gt; of lenses, never one reviewer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design audit&lt;/td&gt;
&lt;td&gt;three design lenses, because a code review is not a design review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verify&lt;/td&gt;
&lt;td&gt;evidence before any "done"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ship&lt;/td&gt;
&lt;td&gt;an 18-line pre-PR gate, pasted into the PR&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rules in &lt;code&gt;/flow&lt;/code&gt; did the most work on this task.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"The governance chain is read from the docs, never inferred."&lt;/strong&gt; My ADRs override my rules, which override the PRD. When a skill's advice contradicts an ADR, the ADR wins, and Claude has to say so out loud. It came up in the first ten minutes. The UI/UX skill's generator recommended orange with a gaming display font for a car-wash app. The project's palette ADR says cobalt and availability-green, and "brand orange" had already been rejected once, so Claude rejected the suggestion and cited the ADR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"An agent never satisfies a gate."&lt;/strong&gt; Subagents do bounded work &lt;em&gt;inside&lt;/em&gt; a gate. The gate takes the approval, and the gate checks the result. It's the difference between five green agent reports and a feature that works.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went well
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Choosing a direction from pictures, not paragraphs
&lt;/h3&gt;

&lt;p&gt;Before a single screen existed, Claude rendered three directions as tappable mockups on a design canvas. Worklist was dense and literal. Bay made money the headline and turned the photo pair into one object. Docket used a work-ticket metaphor. I picked &lt;strong&gt;Bay&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The reasoning behind Bay was genuinely good product thinking. The task file drew the before/after photo gate as an alert card that appears &lt;em&gt;when a transition is already blocked&lt;/em&gt;. Bay makes the pair a persistent two-slot frame from the moment a job is accepted, so the gate becomes a standing obligation instead of an interruption. I wouldn't have asked for that. A good designer brings the idea you didn't ask for.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Review as a stack found bugs that were already in &lt;code&gt;main&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Every task got a spec-plus-quality review and a fix loop. Then the whole branch went through &lt;strong&gt;seven specialist lenses&lt;/strong&gt;, run three at a time: security, silent failures, database, TypeScript, React, test adequacy and spec conformance. Here are six things they, and the live walkthrough, surfaced in code that was &lt;em&gt;already merged&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The valet proof-photo upload had never worked.&lt;/strong&gt; The mobile client posted &lt;code&gt;multipart/form-data&lt;/code&gt;; the API parsed &lt;code&gt;{ proofPhotoId: string }&lt;/code&gt;. Every call returned a 400. The component test injected the broken dependency, so the test couldn't see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The driver's wash screen failed for every real partner.&lt;/strong&gt; It read &lt;code&gt;users.name&lt;/code&gt; into a non-null schema, and nothing in the codebase ever writes &lt;code&gt;users.name&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A business partner could never be verified.&lt;/strong&gt; Only the ID-document endpoint set &lt;code&gt;pending&lt;/code&gt;, and the business form doesn't collect an ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Valet and washer partners opened the app to "Unmatched Route".&lt;/strong&gt; The redirect built &lt;code&gt;/(role)&lt;/code&gt;, and those two route groups had no &lt;code&gt;index.tsx&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The idempotency layer's &lt;code&gt;store&lt;/code&gt;/&lt;code&gt;release&lt;/code&gt; calls were fire-and-forget.&lt;/strong&gt; A failed write left a key locked for 24 hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An unregistered partner got a 404 with the error code &lt;code&gt;ERROR&lt;/code&gt;, not &lt;code&gt;NOT_FOUND&lt;/code&gt;.&lt;/strong&gt; The client couldn't tell "you haven't signed up yet" apart from "the server broke", so it had no way to route to registration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these was a style nit. Each one would have broken a real user.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A ledger of every decision taken on my behalf
&lt;/h3&gt;

&lt;p&gt;The execution skill has a rule I've come to love: &lt;strong&gt;rulings, not stalls&lt;/strong&gt;. When a plan is ambiguous or wrong, the agent decides, and records it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ruling: T6-R1 — a pending washer's offers state is "online switch locked with
the reason", not "offers visible, Accept locked". The server makes the task
file's version unreachable (set-availability.command.ts:43 refuses isOnline
unless verified). — Cost if wrong: a pending-preview feed would be a server
change in task 13's dispatch.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the end there were &lt;strong&gt;42 of these&lt;/strong&gt;, and every one went into the PR body as a table: decision, reason, cost if wrong. That's the part I actually review. It turns "the AI did stuff" into decisions I can overrule.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. It opened the app, and that found what tests can't
&lt;/h3&gt;

&lt;p&gt;The UI gate says a screen isn't done until it has been opened at mobile and desktop widths. Claude drove the web preview and measured things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tab labels were &lt;strong&gt;12px text in a 9px box&lt;/strong&gt;, so they were clipped;&lt;/li&gt;
&lt;li&gt;at a short viewport, the empty-state illustration &lt;strong&gt;covered the online toggle&lt;/strong&gt; and pushed the only button &lt;strong&gt;under the tab bar&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;the switch's "on" colour was react-native-web's default &lt;code&gt;#009688&lt;/code&gt;, not a design token;&lt;/li&gt;
&lt;li&gt;green, which the design system reserves for "available right now", was being used for "done".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A mechanical design detector, run as a separate isolated pass, independently flagged the same occlusion. The design review scored the screens &lt;strong&gt;23/40&lt;/strong&gt;. Every issue was fixed and re-checked live afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went badly
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The AI's own plan had bugs, and reviews caught them late
&lt;/h3&gt;

&lt;p&gt;I want to be honest about this, because the "AI writes perfect code" crowd isn't.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The plan's upload step &lt;strong&gt;sent no idempotency key&lt;/strong&gt;, so every upload would have failed as "check your connection".&lt;/li&gt;
&lt;li&gt;The fix for that was then wrong in the other direction. Reusing the key across retries meant the server replayed a &lt;strong&gt;stale Cloudinary signature for 24 hours&lt;/strong&gt;, and Cloudinary rejects signatures older than an hour. That became an ADR: &lt;em&gt;an idempotency key belongs to a side effect, not to an HTTP call&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;The earnings query's correlated subquery rendered as:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="nv"&gt;"txn_id"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;"txn_id"&lt;/span&gt;   &lt;span class="c1"&gt;-- always true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Every earnings line summed every posting in the ledger.&lt;/strong&gt; It passed two reviews, because every test fixture had exactly one job. A later reviewer caught it with a two-job test.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A plan test asserted &lt;code&gt;rupeesToPaise('0.07') → 7&lt;/code&gt;, which contradicts its own ₹10 minimum. The justification for it ("&lt;code&gt;319.20 * 100&lt;/code&gt; is a float error") was false; in Node it's exactly &lt;code&gt;31920&lt;/code&gt;. The implementer stopped, flagged the contradiction, and didn't bend the test. That's exactly what you want.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson isn't "AI is sloppy". It's that &lt;strong&gt;a plan is a hypothesis&lt;/strong&gt;, and the review stack is what tests it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Fixes introduced regressions
&lt;/h3&gt;

&lt;p&gt;Two fixes from the final fix wave broke something new, and both were caught by the scoped re-review:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;memoising the offer card made a &lt;strong&gt;recycled FlashList cell carry an "expired" flag onto a live offer&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;a stale-lock takeover meant &lt;strong&gt;a committed-but-unstored request could run twice&lt;/strong&gt; (think "second payment order").&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both were fixed before merge. It's also a reminder that "the review passed" is only as good as the review of the fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Wall-clock time, mostly rate limits
&lt;/h3&gt;

&lt;p&gt;The work itself wasn't the slow part. &lt;strong&gt;Agents were cut off by session and weekly rate limits more than five times&lt;/strong&gt;, mid-task. The Sonnet weekly quota, which the implementer and reviewer subagents were running on, ran out on day one. From then on, subagents ran on Opus for anything needing judgment and Haiku for pure transcription. The design of the process saved it: every agent writes its report to disk, every task has a ledger line, and an interrupted agent is &lt;strong&gt;resumed with its context intact&lt;/strong&gt; instead of restarted. Nothing was redone, but the calendar time still stretched across two days.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The environment lied, and it nearly cost a false bug hunt
&lt;/h3&gt;

&lt;p&gt;At one point the camera sheet's shutter measured at &lt;code&gt;y=1564&lt;/code&gt; in an 812px viewport, which looked like a serious layout bug. It wasn't. The browser pane was hidden, so &lt;code&gt;requestAnimationFrame&lt;/code&gt; was firing &lt;strong&gt;zero times a second&lt;/strong&gt;, and the slide-in animation never ran. Claude measured frames per second before diagnosing, and that check is now a documented learning.&lt;/p&gt;

&lt;p&gt;Similarly, the web preview wouldn't start because Metro was crawling &lt;strong&gt;1.6 GB of stale git worktrees&lt;/strong&gt;. The fix was a &lt;code&gt;blockList&lt;/code&gt; in &lt;code&gt;metro.config.js&lt;/code&gt;, not deleting my files, which Claude refused to do without asking. When I did ask, it first showed me which worktrees held uncommitted work.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Opus 5.5 helped
&lt;/h2&gt;

&lt;p&gt;The main session, the "controller" that plans, dispatches and adjudicates, started on Opus 5 and moved to Opus 5.5 early in the task. Everything from the implementation onward ran on it: orchestration, the final seven-lens review, and every ruling. What stood out wasn't raw coding speed. It was judgment in the controller seat:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It said when its own plan was wrong.&lt;/strong&gt; "The defect is in MY plan's Step 4 code, not the implementer's transcription" is a sentence I saw more than once, followed by a ruling and a fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It verified before it asserted.&lt;/strong&gt; Before acting on a reviewer's "Critical", it re-read the code. It confirmed &lt;code&gt;set-availability.command.ts:43&lt;/code&gt; before overruling a line in the task file, and it confirmed &lt;code&gt;errorCodeFor&lt;/code&gt; returns &lt;code&gt;'ERROR'&lt;/code&gt; for a bare &lt;code&gt;NotFoundException&lt;/code&gt; before changing the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It held the line on destructive actions.&lt;/strong&gt; It deleted no worktree with uncommitted work until I chose. It didn't edit my &lt;code&gt;CLAUDE.md&lt;/code&gt; because a subagent suggested it. It didn't open the PR until the gate was green.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It pushed back on scope in both directions.&lt;/strong&gt; It proposed extending the earnings API when the task was unbuildable without it, and it refused the sparkline because it would have required money arithmetic in the client.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How AI actually helps (and where it doesn't)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It helps most as a team.&lt;/strong&gt; Seven reviewers reading the same branch through seven different lenses is something I couldn't staff myself. The database lens found a missing index. The React lens found the recycled-cell bug. The test-adequacy lens found that &lt;strong&gt;no test anywhere proved an unverified partner gets refused&lt;/strong&gt;, the single highest-risk gap in the branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It helps with the tedious truth-telling.&lt;/strong&gt; Every PR claim comes with pasted output: lint 16/16, typecheck 15/15, 1,013 tests, 397 integration tests, 72 commits scanned by gitleaks with zero leaks. Where the gate wasn't green (&lt;code&gt;pnpm audit&lt;/code&gt; has 33 pre-existing findings), the PR says so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not replace a device.&lt;/strong&gt; The real camera, the real Cloudinary upload, TalkBack on Android and the Maestro end-to-end flows are all explicitly listed in the PR as &lt;strong&gt;not verified here&lt;/strong&gt;. An honest "I couldn't check this" beats a confident green checkmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  The token-optimisation playbook
&lt;/h2&gt;

&lt;p&gt;Long agentic sessions die from context bloat before they die from bad code. Here's what we did, all of it visible in the session:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hand artifacts over as files, not pastes.&lt;/strong&gt; Each implementer gets a &lt;em&gt;task brief&lt;/em&gt; extracted from the plan (never the whole plan). Each reviewer gets a &lt;em&gt;precomputed diff package&lt;/em&gt;: commit list, stat and full diff in one file. The controller never pastes a diff into a prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short return contracts.&lt;/strong&gt; Every subagent writes its full report to disk and returns under 15 lines: status, commits, one-line test summary, concerns. The detail lives in the file, and the controller reads it only when needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tiered models.&lt;/strong&gt; Haiku for transcription tasks and small scoped re-reviews, and for running the pre-PR gate commands. Opus for money, security, concurrency and design judgment. The rule of thumb: &lt;em&gt;turn count beats token price&lt;/em&gt;, so the cheapest model isn't cheap if it takes three times the turns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoped re-reviews.&lt;/strong&gt; After a fix, the reviewer sees only the fix diff and the findings list, and gives a verdict of ADDRESSED or NOT ADDRESSED per finding. No re-reviewing the world.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slice big diffs by surface.&lt;/strong&gt; The final branch diff was 700 KB. It was split into server source, server tests, mobile source and mobile tests, and each lens read only its slice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap the output.&lt;/strong&gt; "At most 12 findings, severity-ranked, under 600 words, no praise section." Praise is tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch lenses three at a time&lt;/strong&gt;, so a burst of parallel work doesn't blow the rate limit, the one resource we kept running out of.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resume, don't restart.&lt;/strong&gt; A rate-limited agent is resumed with its context rather than re-dispatched from scratch. The largest implementer run used around 520k tokens; redoing that would have been the most expensive mistake available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A ledger as recovery memory.&lt;/strong&gt; &lt;code&gt;progress.md&lt;/code&gt; records every task's status and every ruling. After an interruption, the ledger and &lt;code&gt;git log&lt;/code&gt; beat the controller's recollection, and no completed task was ever re-dispatched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure before you investigate.&lt;/strong&gt; One &lt;code&gt;requestAnimationFrame&lt;/code&gt; count replaced what could have been an hour of chasing a phantom layout bug.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Our &lt;code&gt;/flow&lt;/code&gt; also has a local &lt;strong&gt;knowledge graph&lt;/strong&gt; of the codebase (tree-sitter, no embeddings) for "what calls what" questions, which saves ten greps with one query. In this task I'll admit we still grepped more than we queried. That's the next habit to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The most valuable output of this task wasn't the 22,800 lines. It was &lt;strong&gt;six bugs that were already shipped&lt;/strong&gt;, a &lt;strong&gt;42-row table of decisions I can overrule&lt;/strong&gt;, and a PR that tells me exactly what it did &lt;em&gt;not&lt;/em&gt; verify.&lt;/p&gt;

&lt;p&gt;AI didn't make this fast. Rate limits and my own process made sure of that. What it did was make the work &lt;strong&gt;inspectable&lt;/strong&gt;: every claim has evidence, every decision has a cost-if-wrong, and every gap has a row in the backlog. For a solo developer building something that moves real money, I'll take inspectable over fast.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ParkEase is built with Claude Code, Expo SDK 57, NestJS 11 on Fastify, Drizzle, and PostgreSQL 18 + PostGIS. The task 14 PR: &lt;a href="https://github.com/Deonkar/parkease/pull/14" rel="noopener noreferrer"&gt;https://github.com/Deonkar/parkease/pull/14&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>reactnative</category>
      <category>claude</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Poison Messages in Kafka: Retries, a Dead-Letter Queue, and the Replay Shortcut I'd Fix</title>
      <dc:creator>Onkar Deokate</dc:creator>
      <pubDate>Tue, 29 Sep 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/rakno/poison-messages-in-kafka-retries-a-dead-letter-queue-and-the-replay-shortcut-id-fix-1p3l</link>
      <guid>https://dev.to/rakno/poison-messages-in-kafka-retries-a-dead-letter-queue-and-the-replay-shortcut-id-fix-1p3l</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; One bad message can stop a Kafka consumer, because offsets are committed in order: if record #41 can never be processed, record #42 never gets processed either. In TxFlow I handle this with bounded retries plus exponential backoff, a dead-letter topic, and a DLQ handler that makes failures visible and replayable. This post covers the design, the order of operations that keeps it safe, and the replay shortcut I'd fix before calling it production-ready.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;Continuing my TxFlow posts. Earlier: &lt;a href="https://dev.to/rakno/why-i-stopped-using-sqs-and-built-a-kafka-system-from-scratch-43m5"&gt;why I moved from SQS to Kafka&lt;/a&gt; and &lt;a href="https://dev.to/rakno/at-least-once-means-sometimes-twice-idempotent-kafka-consumers-with-redis-and-postgres-48ag"&gt;making consumers idempotent&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: poison messages
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;poison message&lt;/strong&gt; is a record your consumer will never successfully process: malformed JSON, a missing field, a user that doesn't exist, a currency you don't support.&lt;/p&gt;

&lt;p&gt;With SQS or Sidekiq, a failing message just sits in its own retry cycle while everything else keeps flowing. Kafka is different. A consumer group tracks &lt;strong&gt;one offset per partition&lt;/strong&gt;, and committing offset 42 means "everything up to 42 is done". If you can't get past 41, you can't commit, and the partition is stuck. Consumer lag grows, and every user whose events hash to that partition waits.&lt;/p&gt;

&lt;p&gt;You can reproduce this in TxFlow with one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{bad json'&lt;/span&gt; | docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; redpanda &lt;span class="se"&gt;\&lt;/span&gt;
  rpk topic produce payments.initiated &lt;span class="nt"&gt;-X&lt;/span&gt; &lt;span class="nv"&gt;brokers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;redpanda:9092 &lt;span class="nt"&gt;-k&lt;/span&gt; user_999
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: separate "try again" from "never going to work"
&lt;/h2&gt;

&lt;p&gt;Not every failure deserves a retry:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transient:&lt;/strong&gt; database timeout, broker hiccup, downstream 503. Retrying often works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permanent:&lt;/strong&gt; can't parse, fails validation, business rule rejects it. Retrying just wastes time while the partition waits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simplified sketch of the consumer framework's shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;MAX_ATTEMPTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_with_retries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handler&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="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_and_validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;value&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ValidationError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;send_to_dlq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&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="c1"&gt;# no retries
&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MAX_ATTEMPTS&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="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="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;MAX_ATTEMPTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;send_to_dlq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exhausted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&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="c1"&gt;# 0.2s, 0.4s...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TxFlow retries &lt;strong&gt;up to 3 times with exponential backoff&lt;/strong&gt;, inside the consumer. That's a deliberate trade-off. While a record is being retried, the rest of its partition waits. At demo volume, a second of blocking is fine. At high volume, you'd use &lt;strong&gt;retry topics&lt;/strong&gt; instead (&lt;code&gt;payments.retry.30s&lt;/code&gt;, &lt;code&gt;payments.retry.5m&lt;/code&gt;, and so on), so the main topic keeps moving while failed records wait their turn somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: a useful DLQ envelope
&lt;/h2&gt;

&lt;p&gt;"Sent to DLQ" is only useful if someone can later work out &lt;strong&gt;what&lt;/strong&gt; failed, &lt;strong&gt;where&lt;/strong&gt; and &lt;strong&gt;why&lt;/strong&gt;. The envelope should carry enough to debug and replay without digging through logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"original_topic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"payments.initiated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"partition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_999"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"consumer_group"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wallet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"exhausted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"InsufficientFunds: user_999"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attempts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"failed_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-29T10:12:03Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"original event, untouched"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;consumer_group&lt;/code&gt; matters most. The same event can be fine for four groups and fail for one. When you replay it, you want only that one group to process it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: order of operations
&lt;/h2&gt;

&lt;p&gt;The sequence that keeps this safe:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Produce&lt;/strong&gt; the envelope to &lt;code&gt;payments.dlq&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait for the broker to acknowledge it&lt;/strong&gt; (flush)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then&lt;/strong&gt; commit the original offset&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you commit first and crash before the DLQ write lands, the failure disappears without a trace. If you write to the DLQ first and crash before committing, the worst case is a &lt;strong&gt;duplicate DLQ entry&lt;/strong&gt;. That's easy to dedupe and much easier to live with than a lost failure.&lt;/p&gt;

&lt;p&gt;TxFlow's DLQ topic has &lt;strong&gt;one partition&lt;/strong&gt;. DLQ traffic is low, and a single ordered stream is easier to inspect and replay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: make failures visible
&lt;/h2&gt;

&lt;p&gt;A DLQ nobody looks at is just a slower way to lose data. TxFlow's &lt;code&gt;dlq-handler&lt;/code&gt; service:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;consumes &lt;code&gt;payments.dlq&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;stores each failure in a &lt;code&gt;dead_letter_events&lt;/code&gt; table in Postgres,&lt;/li&gt;
&lt;li&gt;exposes &lt;code&gt;GET /dlq&lt;/code&gt; for the dashboard, which shows recent failures with search, filters and a &lt;strong&gt;Replay&lt;/strong&gt; button,&lt;/li&gt;
&lt;li&gt;sits next to &lt;strong&gt;consumer lag per group&lt;/strong&gt; on the same dashboard, because a growing DLQ and a growing lag are the two signals that something's wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Not everything needs a DLQ
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;analytics&lt;/strong&gt; consumer has no DLQ at all. It increments counters, and if an event can't be counted after retries, it isn't parked anywhere. A DLQ is an operational commitment: someone has to look at it. I only give one to consumers where a skipped event actually costs something (wallet, fraud, notify, audit).&lt;/p&gt;

&lt;h2&gt;
  
  
  The replay shortcut I'd fix
&lt;/h2&gt;

&lt;p&gt;Today, the dashboard's Replay button sends the payment back through &lt;code&gt;POST /payment&lt;/code&gt;. That's fine for a demo, but it's the wrong layer, and it's the first thing on my improvement list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the API either rejects it (&lt;code&gt;409&lt;/code&gt;, if the original idempotency key is reused) or produces a &lt;strong&gt;fresh event&lt;/strong&gt;,&lt;/li&gt;
&lt;li&gt;a fresh event gets a &lt;strong&gt;new &lt;code&gt;event_id&lt;/code&gt;&lt;/strong&gt;, so it doesn't match any consumer's dedup keys. &lt;strong&gt;All five&lt;/strong&gt; groups process it again, not just the one that failed. A failure in the notify consumer could end up re-running the wallet debit,&lt;/li&gt;
&lt;li&gt;the link to the original event is lost, which makes debugging harder.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The better design &lt;strong&gt;re-publishes the original event&lt;/strong&gt; (same &lt;code&gt;event_id&lt;/code&gt;, same payload) to a replay or retry topic that &lt;strong&gt;only the failed consumer group&lt;/strong&gt; reads. Every other group's idempotency stays intact, and the audit trail shows it was a replay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;p&gt;If you're adding a DLQ to a Kafka consumer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Send permanent failures (parsing, validation) straight to the DLQ, without retrying&lt;/li&gt;
&lt;li&gt;Keep in-consumer retries &lt;strong&gt;few and short&lt;/strong&gt;, or move to retry topics&lt;/li&gt;
&lt;li&gt;Write to the DLQ, wait for the ack, &lt;strong&gt;then&lt;/strong&gt; commit the offset&lt;/li&gt;
&lt;li&gt;Put &lt;code&gt;consumer_group&lt;/code&gt;, topic/partition/offset, error and attempt count in the envelope&lt;/li&gt;
&lt;li&gt;Store DLQ entries somewhere queryable, with a dashboard or alert&lt;/li&gt;
&lt;li&gt;Replay to the failed group only, with the original event ID&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Question for you:&lt;/strong&gt; do you use in-consumer retries or retry topics? And what's the strangest poison message you've found in a DLQ?&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>systemdesign</category>
      <category>python</category>
      <category>backend</category>
    </item>
    <item>
      <title>Money in a Parking App: Paise, Basis Points and a Ledger Postgres Won't Let You Edit</title>
      <dc:creator>Onkar Deokate</dc:creator>
      <pubDate>Mon, 28 Sep 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/rakno/money-in-a-parking-app-paise-basis-points-and-a-ledger-postgres-wont-let-you-edit-42ed</link>
      <guid>https://dev.to/rakno/money-in-a-parking-app-paise-basis-points-and-a-ledger-postgres-wont-let-you-edit-42ed</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Money in ParkEase is stored as integer &lt;strong&gt;paise&lt;/strong&gt;, rates as &lt;strong&gt;basis points&lt;/strong&gt;, and every rupee movement is written to a &lt;strong&gt;double-entry ledger&lt;/strong&gt; that Postgres refuses to update or delete. This post covers how a price gets calculated and checked twice, and how refunds get split without drifting by one paisa. It also covers why the refund split uses &lt;code&gt;BigInt&lt;/code&gt; even though amounts are just rupees.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;Part 3 of my series on building ParkEase, a peer-to-peer parking marketplace for India. Earlier parts covered double-booking prevention with exclusion constraints and a pagination bug inside a geo-search cache.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: no floats, ever
&lt;/h2&gt;

&lt;p&gt;Every amount is an integer number of &lt;strong&gt;paise&lt;/strong&gt; (₹1 = 100 paise), stored as &lt;code&gt;bigint&lt;/code&gt;. Every rate is either a branded &lt;code&gt;Rate&lt;/code&gt; type in code or &lt;strong&gt;basis points&lt;/strong&gt; in the database (1.0x surge is stored as &lt;code&gt;10000&lt;/code&gt;).&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="n"&gt;base_paise&lt;/span&gt;           &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;surge_premium_paise&lt;/span&gt;  &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;surge_multiplier_bp&lt;/span&gt;  &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;parkease_fee_paise&lt;/span&gt;   &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;gst_paise&lt;/span&gt;            &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;total_paise&lt;/span&gt;          &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;owner_earnings_paise&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The booking stores the &lt;strong&gt;whole quote&lt;/strong&gt;, not just the total. The price a driver agreed to can be rebuilt from the booking row alone, without re-running whatever the rate card said that day.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a price is built
&lt;/h2&gt;

&lt;p&gt;The model: the owner sets a base price. ParkEase takes a 15% commission from the owner's side. Surge (1.0x–3.0x) adds a premium that goes to the platform. GST at 18% applies &lt;strong&gt;only to the ParkEase fee&lt;/strong&gt;, not to the whole booking.&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;function&lt;/span&gt; &lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FeeInput&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;gstRate&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Rate&lt;/span&gt; &lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nx"&gt;Quote&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;fee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parkEaseFee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                    &lt;span class="c1"&gt;// commission + surge premium&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gstPaise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mulRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parkeaseFeePaise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gstRate&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;ownerEarningsPaise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;subPaise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;basePaise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;commissionPaise&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;driverTotalPaise&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;addPaise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;basePaise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;surgePremiumPaise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gstPaise&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gstPaise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;driverTotalPaise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ownerEarningsPaise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* bp fields */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;assertQuoteBalances&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&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;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A worked example with a ₹100 base price and 1.5x surge:&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;paise&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Base&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Surge premium (0.5 × base)&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commission (15% of base)&lt;/td&gt;
&lt;td&gt;1,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;ParkEase fee&lt;/strong&gt; (commission + surge)&lt;/td&gt;
&lt;td&gt;6,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;GST&lt;/strong&gt; (18% of fee)&lt;/td&gt;
&lt;td&gt;1,170&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Driver pays&lt;/strong&gt; (base + surge + GST)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16,170&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Owner earns&lt;/strong&gt; (base − commission)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8,500&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Check: owner 8,500 + fee 6,500 + GST 1,170 = &lt;strong&gt;16,170&lt;/strong&gt;. Every paisa the driver pays ends up somewhere specific.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checked twice: in code and in the database
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;assertQuoteBalances&lt;/code&gt; throws if &lt;code&gt;owner + fee + GST ≠ driver total&lt;/code&gt;. The database checks the same thing again:&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;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;bookings_balance_check&lt;/span&gt;
  &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_paise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base_paise&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;surge_premium_paise&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gst_paise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks redundant, and that's the point. The TypeScript check catches a bad formula in tests. The &lt;code&gt;CHECK&lt;/code&gt; catches the code path I haven't written yet: a future admin tool, a data fix, or a migration. Booking extensions &lt;strong&gt;add&lt;/strong&gt; to each money column instead of replacing them, so the constraint keeps holding after an extension too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rates have history
&lt;/h2&gt;

&lt;p&gt;Tax rates change, and commission rates will too. So rates are dated lists, not constants:&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;GST_RATE_HISTORY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;DatedRate&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;toRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.18&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;effectiveFrom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-01-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;18% on the ParkEase Fee only, not on the booking total.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rateAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;DatedRate&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;at&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;Date&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt; &lt;span class="nx"&gt;Rate&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;applicable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&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="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="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;effectiveFrom&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;T00:00:00+05:30`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// IST midnight&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&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="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;applicable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`No rate in force at &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;applicable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rate&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 small details I like here. The effective date is &lt;strong&gt;midnight IST&lt;/strong&gt;, not UTC, because that's when an Indian rate change actually takes effect. And tax items I haven't confirmed yet (TCS/TDS) are listed at 0% with a note saying they're pending review by a chartered accountant, so the open question is written down in the code itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ledger: double-entry, append-only
&lt;/h2&gt;

&lt;p&gt;Every money movement is a set of &lt;code&gt;ledger_entries&lt;/code&gt; that share a &lt;code&gt;txn_id&lt;/code&gt;. Each entry is a debit or credit against a named account:&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;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;ledger_entries_account_check&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'driver_receivable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'owner_payable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'platform_revenue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'gst_payable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'tcs_payable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'tds_payable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'gateway_fees'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'refunds_payable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'promo_expense'&lt;/span&gt;
&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;ledger_entries_amount_check&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_paise&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ledger service checks that the debits and credits balance &lt;strong&gt;before&lt;/strong&gt; inserting, and it only ever runs inside the caller's transaction:&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="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TxHandle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;posting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LedgerPosting&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;assertEntriesBalance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;posting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// fails the whole transaction, naming the bad posting&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;txnId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;posting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;txnId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;uuidv7&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ledgerEntries&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;posting&lt;/span&gt;&lt;span class="p"&gt;.&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;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="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;txnId&lt;/span&gt;&lt;span class="p"&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;txnId&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;An unbalanced posting aborts the business operation that caused it, instead of landing in the table for a nightly check to find. The nightly &lt;code&gt;ledger-balance&lt;/code&gt; job still exists as a second check.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Append-only" enforced by the database
&lt;/h3&gt;

&lt;p&gt;Mistakes in a ledger get fixed with a &lt;strong&gt;reversing entry&lt;/strong&gt;, never an edit. Two layers make sure of that:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;ledger_entries_reject_mutation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;EXCEPTION&lt;/span&gt; &lt;span class="s1"&gt;'ledger_entries is append-only. Post a reversing entry instead of a %'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TG_OP&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;ERRCODE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'restrict_violation'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;ledger_entries_no_mutation&lt;/span&gt;
  &lt;span class="k"&gt;BEFORE&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ledger_entries&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;ledger_entries_reject_mutation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;TRUNCATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ledger_entries&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;parkease_app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ledger_entries&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;parkease_app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why both? The &lt;strong&gt;&lt;code&gt;REVOKE&lt;/code&gt;&lt;/strong&gt; stops the application role outright. The &lt;strong&gt;trigger&lt;/strong&gt; catches a privileged session, such as a migration or someone in &lt;code&gt;psql&lt;/code&gt; with the owner role. Row-level triggers don't fire on &lt;code&gt;TRUNCATE&lt;/code&gt;, which is one reason the &lt;code&gt;REVOKE&lt;/code&gt; includes it. A superuser can still disable triggers, so this is a strong guardrail rather than tamper-proofing. It turns "someone edited a ledger row" from a risk you might never notice into something that takes a deliberate action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Splitting refunds without losing a paisa
&lt;/h2&gt;

&lt;p&gt;A partial refund has to be spread back across the owner's share, the fee and the GST. The obvious approach, &lt;code&gt;mulRate(owner, 0.5)&lt;/code&gt; and then the same for the fee and the GST, &lt;strong&gt;rounds three times on its own&lt;/strong&gt;, and three roundings can be off by one paisa in total. In this system, one paisa off means an unbalanced posting, and &lt;code&gt;assertEntriesBalance&lt;/code&gt; would reject the whole refund transaction.&lt;/p&gt;

&lt;p&gt;So refunds use &lt;strong&gt;largest-remainder allocation&lt;/strong&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;function&lt;/span&gt; &lt;span class="nf"&gt;allocateProportionally&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalPaise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;divisor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;w&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shares&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;weights&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;w&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;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalPaise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;divisor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;remainder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;divisor&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;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;shares&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;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;spare&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;totalPaise&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&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="c1"&gt;// Hand the leftover paise to the largest remainders; ties go to the lower index.&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="nx"&gt;shares&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;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&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="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;remainder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remainder&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&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="nx"&gt;b&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remainder&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remainder&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="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&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="nx"&gt;remainder&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remainder&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;for &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;index&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spare&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&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;spare&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// always sums to exactly totalPaise&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions in there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic tie-breaking.&lt;/strong&gt; A refund recalculated during an incident investigation has to match the one that was actually written.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;BigInt&lt;/code&gt; for the multiplication.&lt;/strong&gt; ₹1 crore is 10⁹ paise. Multiply that by a weight of similar size and you're past 2⁵³, where JavaScript numbers stop being exact integers. The biggest amounts are exactly where a silent rounding error would hurt most.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;None of this is exotic. It's integers, &lt;code&gt;CHECK&lt;/code&gt; constraints, one trigger and a 40-line function. Put together, they make whole categories of money bugs impossible to write instead of just unlikely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question for you:&lt;/strong&gt; do you enforce append-only tables in the database, or trust the application layer? And has anyone here been bitten by float money in JavaScript?&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>typescript</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Sidekiq to Kafka: A Mental-Model Map for Rails Developers</title>
      <dc:creator>Onkar Deokate</dc:creator>
      <pubDate>Sun, 27 Sep 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/rakno/sidekiq-to-kafka-a-mental-model-map-for-rails-developers-1ibl</link>
      <guid>https://dev.to/rakno/sidekiq-to-kafka-a-mental-model-map-for-rails-developers-1ibl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; If you've spent years with Sidekiq, Kafka can look like "Sidekiq with extra steps". It isn't. Sidekiq moves &lt;strong&gt;jobs&lt;/strong&gt; (commands to do something). Kafka stores &lt;strong&gt;events&lt;/strong&gt; (facts about what happened) in a log that many readers can each read at their own pace. This is the concept map I wish I'd had when I moved from Sidekiq and SQS at work to building a Kafka system myself, including the Sidekiq features you'll have to rebuild yourself.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Where I'm coming from
&lt;/h2&gt;

&lt;p&gt;My day job is mostly Ruby on Rails: background work runs through &lt;strong&gt;Sidekiq&lt;/strong&gt;, and some integrations use &lt;strong&gt;SQS&lt;/strong&gt;. Those tools are great, and for most Rails apps they're the right choice.&lt;/p&gt;

&lt;p&gt;Then I built &lt;a href="https://github.com/Deonkar/txFlow" rel="noopener noreferrer"&gt;TxFlow&lt;/a&gt;, where a single payment event has to trigger &lt;strong&gt;five independent reactions&lt;/strong&gt; (fraud, wallet, notification, audit, analytics). That's where my Sidekiq mental model started giving me wrong answers. Here's the map I ended up drawing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-table version
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sidekiq&lt;/th&gt;
&lt;th&gt;Kafka&lt;/th&gt;
&lt;th&gt;What actually changes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Job&lt;/strong&gt; (&lt;code&gt;perform_async&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Event / record&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A job says "do X". An event says "X happened".&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Queue&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Topic + partitions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A queue is emptied as jobs are processed. A topic keeps records until retention expires.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker processes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Consumer group&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Each group reads &lt;em&gt;every&lt;/em&gt; event, independently.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enqueue the same job N times for N handlers&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;N consumer groups&lt;/strong&gt; on one topic&lt;/td&gt;
&lt;td&gt;The producer doesn't know who's listening.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Built-in retries (25 over ~20 days)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;You build it&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Kafka has no per-message retry.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dead set&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dead-letter topic&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Also something you build.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No ordering guarantee&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Ordering per partition&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Key by &lt;code&gt;user_id&lt;/code&gt; to keep one user's events in order.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can't replay a finished job&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Reset offsets and re-read&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The log is still there.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unique jobs (Enterprise / gems)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Idempotent consumers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Duplicates are normal, so you design for them.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale by adding threads/processes&lt;/td&gt;
&lt;td&gt;Scale &lt;strong&gt;up to the partition count&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;3 partitions = at most 3 active consumers per group.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rest of this post goes through the rows that surprised me.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Jobs vs events changes who owns the coupling
&lt;/h2&gt;

&lt;p&gt;In Sidekiq, the code that creates a payment usually knows every follow-up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/services/place_payment.rb&lt;/span&gt;
&lt;span class="no"&gt;FraudCheckJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;DebitWalletJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;SendReceiptJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;AuditLogJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a fifth reaction means editing this file. With Kafka, the producer publishes &lt;strong&gt;one&lt;/strong&gt; fact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;produce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payments.initiated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new reaction is a new consumer group that subscribes to the topic. The producer never changes. That was the real reason I moved to Kafka for this project. Throughput had nothing to do with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A consumer group isn't a worker pool, it's a subscriber
&lt;/h2&gt;

&lt;p&gt;This one confused me at first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same group, many instances:&lt;/strong&gt; the instances &lt;em&gt;split&lt;/em&gt; the partitions between them (like Sidekiq processes sharing a queue).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different groups:&lt;/strong&gt; each group gets a &lt;em&gt;full copy&lt;/em&gt; of every event.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "five things react to a payment" is five consumer groups, not five queues and not five job classes.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The retries you get for free in Sidekiq are gone
&lt;/h2&gt;

&lt;p&gt;Sidekiq's error handling is so good it's easy to take for granted: &lt;strong&gt;25 retries with exponential backoff over roughly 20 days&lt;/strong&gt;, then the job goes to the &lt;strong&gt;Dead set&lt;/strong&gt; (kept up to 10,000 jobs or 6 months), plus a &lt;code&gt;sidekiq_retries_exhausted&lt;/code&gt; hook.&lt;/p&gt;

&lt;p&gt;Kafka gives you none of that. If a consumer keeps failing on one record, it &lt;strong&gt;blocks that partition&lt;/strong&gt;, because offsets are committed in order. In TxFlow I had to build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in-process retries with exponential backoff (3 attempts),&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;payments.dlq&lt;/code&gt; topic for records that still fail,&lt;/li&gt;
&lt;li&gt;a DLQ handler service that stores failures in Postgres and shows them on a dashboard with a replay button.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's not hard, but it's real work. Plan for it when you estimate a Kafka migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Delivery guarantees: different defaults than you'd think
&lt;/h2&gt;

&lt;p&gt;A detail many Rails developers don't know: open-source Sidekiq fetches jobs with &lt;code&gt;BRPOP&lt;/code&gt;, which &lt;strong&gt;removes the job from Redis when it's fetched&lt;/strong&gt;. If the process is killed hard (&lt;code&gt;kill -9&lt;/code&gt;, OOM) mid-job, &lt;a href="https://github.com/sidekiq/sidekiq/wiki/Reliability" rel="noopener noreferrer"&gt;that job is lost&lt;/a&gt;. On a normal shutdown, Sidekiq pushes unfinished jobs back. &lt;strong&gt;Sidekiq Pro's &lt;code&gt;super_fetch&lt;/code&gt;&lt;/strong&gt; closes that gap by keeping in-flight jobs in Redis until they finish.&lt;/p&gt;

&lt;p&gt;With Kafka and &lt;strong&gt;manual offset commits&lt;/strong&gt;, a crash mid-processing means the record is &lt;strong&gt;delivered again&lt;/strong&gt;. You get at-least-once delivery by default, and the price is that &lt;strong&gt;duplicates are normal&lt;/strong&gt;. Every consumer with side effects needs to be idempotent (I use a Redis dedup key plus a database primary key for the wallet debit).&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Ordering is per partition, and you choose the key
&lt;/h2&gt;

&lt;p&gt;Sidekiq makes no ordering promises between jobs. Kafka guarantees order &lt;strong&gt;within a partition&lt;/strong&gt;, and the record's key decides the partition. TxFlow keys payment events by &lt;code&gt;user_id&lt;/code&gt;, so all of one user's events land on one partition, in order. That's what you want for applying balance changes.&lt;/p&gt;

&lt;p&gt;The flip side is that your parallelism is capped by partition count. You can't add a fourth active consumer to a group reading a 3-partition topic and expect it to do anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Replay is the superpower
&lt;/h2&gt;

&lt;p&gt;A finished Sidekiq job is gone. A Kafka event stays in the log until retention expires. So you can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;start a &lt;strong&gt;new consumer group&lt;/strong&gt; months later and have it read history (as far back as retention allows),&lt;/li&gt;
&lt;li&gt;fix a bug in a consumer and &lt;strong&gt;reprocess&lt;/strong&gt; a time range,&lt;/li&gt;
&lt;li&gt;rebuild a projection like analytics counters from scratch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test that made it click for me: stop one consumer, fire 10 payments, restart it, and watch it catch up while the other four groups never noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I'd stay on Sidekiq
&lt;/h2&gt;

&lt;p&gt;Most of the time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the work is a &lt;strong&gt;command&lt;/strong&gt; with one handler (send this email, resize this image),&lt;/li&gt;
&lt;li&gt;you want rich retries and a UI without building them,&lt;/li&gt;
&lt;li&gt;you don't need replay or multiple independent subscribers,&lt;/li&gt;
&lt;li&gt;your team doesn't want to run a broker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Kafka earns its operational cost when you have &lt;strong&gt;one fact and many independent reactions&lt;/strong&gt;, need &lt;strong&gt;replay&lt;/strong&gt;, or need &lt;strong&gt;ordering per entity&lt;/strong&gt; at scale. If that's not you, Sidekiq and Postgres will take you a very long way.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Question for Rails folks:&lt;/strong&gt; what's the one Sidekiq feature you'd miss most if you moved to Kafka? My guess is the retry UI.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>kafka</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
