<?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: Rafael Soares</title>
    <description>The latest articles on DEV Community by Rafael Soares (@rsoaresdev).</description>
    <link>https://dev.to/rsoaresdev</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%2F4141532%2F6cb9f643-2bcc-4a81-81ab-a50dff6521ee.jpeg</url>
      <title>DEV Community: Rafael Soares</title>
      <link>https://dev.to/rsoaresdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rsoaresdev"/>
    <language>en</language>
    <item>
      <title>React Native Widgets Without Duplicating Business Logic in Swift</title>
      <dc:creator>Rafael Soares</dc:creator>
      <pubDate>Fri, 16 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/rsoaresdev/react-native-widgets-without-duplicating-business-logic-in-swift-46gg</link>
      <guid>https://dev.to/rsoaresdev/react-native-widgets-without-duplicating-business-logic-in-swift-46gg</guid>
      <description>&lt;p&gt;"Next service" is a short label with a surprisingly long list of dependencies.&lt;/p&gt;

&lt;p&gt;In OdoKeep, it can depend on the vehicle, its recorded maintenance, the distance unit, the user's snooze decisions, and the current service forecast. Monthly spend has another set of rules, including converting a record at its own date when it was paid in a different currency.&lt;/p&gt;

&lt;p&gt;Those answers already existed in TypeScript. Adding iOS widgets would have been a good opportunity to copy them into Swift and spend the next year fixing disagreements between the two versions.&lt;/p&gt;

&lt;p&gt;Instead, the app writes the answers down.&lt;/p&gt;

&lt;p&gt;OdoKeep is built with React Native and Expo. It has four WidgetKit widgets: vehicle details, deadlines, monthly spend, and servicing. The widget extension is a separate process. It does not run React Native or open the app's MMKV store.&lt;/p&gt;

&lt;p&gt;The boundary between the two processes is a JSON snapshot in an App Group container. The TypeScript app builds it from the same modules used by the dashboard. Swift decodes it and draws the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Share finished answers across the process boundary
&lt;/h2&gt;

&lt;p&gt;The snapshot is a presentation contract, rather than an export of raw records. Amounts are formatted, units are applied, service labels are translated, and deep links are already built. Even the spend-chart buckets carry their finished labels and normalized bar ratios.&lt;/p&gt;

&lt;p&gt;The following excerpt shows the kind of shape the contract uses:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;WidgetSpendBucket&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;label&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="nl"&gt;ratio&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;isCurrent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;WidgetDeadline&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;label&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="nl"&gt;vehicleId&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="nl"&gt;vehicleName&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="nl"&gt;dueDate&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="nl"&gt;dueDateLabel&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="nl"&gt;warningFrom&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="nl"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is still useful structure in the file. Swift needs identities to support vehicle selection, dates to advance the display, and ratios to render bars. What it does not need is a second implementation of the garage's financial or maintenance rules.&lt;/p&gt;

&lt;p&gt;The dashboard's attention list, service forecast, and spend calculations feed &lt;code&gt;lib/widgets/snapshot.ts&lt;/code&gt;. &lt;code&gt;plugins/widgets/WidgetsSnapshot.swift&lt;/code&gt; mirrors the decoder contract. When a field changes meaning, the shared snapshot version changes too. A decoder that does not understand that version falls back to asking the user to open the app.&lt;/p&gt;

&lt;p&gt;That is a much easier failure to investigate than a widget confidently showing the wrong interpretation of a new field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the calendar advance without the app
&lt;/h2&gt;

&lt;p&gt;The calendar is the deliberate exception to precomputed answers. A widget can remain visible while the app is unopened for days. A deadline that says "in three days" has to change after midnight without asking React Native to rebuild it.&lt;/p&gt;

&lt;p&gt;The snapshot therefore stores a deadline as a local calendar day and a &lt;code&gt;warningFrom&lt;/code&gt; day. It also stores translated countdown strings keyed by day offset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"0"  -&amp;gt; Today
"1"  -&amp;gt; Tomorrow
"2"  -&amp;gt; In 2 days
"-2" -&amp;gt; 2 days overdue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real table is produced through the app's translation function and extends sixty days into the past and 120 into the future, with an overdue fallback. Swift counts calendar days and looks up the corresponding finished string. It can advance the state at the precomputed warning date without learning how the app decided that date.&lt;/p&gt;

&lt;p&gt;For services measured in distance, the snapshot carries a fixed label instead. Waiting overnight does not add kilometers to an odometer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;WidgetTimeline.swift&lt;/code&gt; builds an entry for now and one per local midnight for the next week, with an &lt;code&gt;.atEnd&lt;/code&gt; refresh policy. The snapshot stays the same while the entry date changes. This supplies future display states to WidgetKit; it does not promise that an extension runs at an exact wall-clock instant. WidgetKit controls its refresh schedule, as Apple's &lt;a href="https://developer.apple.com/documentation/widgetkit/keeping-a-widget-up-to-date" rel="noopener noreferrer"&gt;documentation on keeping widgets up to date&lt;/a&gt; explains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replace the snapshot before requesting a reload
&lt;/h2&gt;

&lt;p&gt;Choosing when to write the file is another part of the design. The main write happens as the app leaves the foreground, close to the moment the user returns to the home screen. A four-second timer after mount supplies an initial snapshot for a session that has not backgrounded yet. Observable preference and configuration changes re-arm that timer.&lt;/p&gt;

&lt;p&gt;The record repository does not expose a subscription for every record write, so a record added after the timer fires reaches the snapshot on the next background transition. That is the freshness boundary. A widget also cannot learn about a shared vehicle edit that the app has not yet pulled.&lt;/p&gt;

&lt;p&gt;The writer uses synchronous file operations on that lifecycle path. It writes the entire JSON document under a scratch name, then moves it over the real snapshot with overwrite enabled. This keeps the normal decoder path away from a half-written document. The scratch file is removed before reuse if an earlier attempt was interrupted.&lt;/p&gt;

&lt;p&gt;Only after replacement does the small Expo native module request a WidgetKit timeline reload. Writing bytes alone would leave the existing timeline in place until the system requested another one. Reloading is a request to the scheduler, so freshness still depends on iOS and a successful snapshot write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy belongs in the snapshot builder
&lt;/h2&gt;

&lt;p&gt;The data allowed across the boundary is narrower than the data available in the app. When the biometric lock is enabled, the widget snapshot contains no odometer or money values. Those fields are null; the sensitive values are not serialized and then hidden in the view. The license plate is never included, regardless of lock state. Deadlines and service information remain available.&lt;/p&gt;

&lt;p&gt;The Swift views also mark sensitive values for system privacy redaction. That supports iOS's display controls, while the builder controls which values reach the extension at all.&lt;/p&gt;

&lt;p&gt;Sign-out and account deletion remove the widget snapshot and request a reload. Deleting the file is part of ending the session, not just a side effect of unmounting the hook that writes it. Otherwise, a saved snapshot could outlive the account that produced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reuse the boundary for Siri and Shortcuts
&lt;/h2&gt;

&lt;p&gt;The same architectural choice supports Siri and Shortcuts. Their read intents use a separate presentation snapshot stored in the app's Documents directory. Those intents compile into the app target; they are not the WidgetKit extension. The builders share inputs, but the files and consumers have distinct contracts.&lt;/p&gt;

&lt;p&gt;A write shortcut opens a deep link into an existing app form. It does not update storage from Swift. The route still applies the share role, plan limits, odometer checks, and sync behavior that apply when the user opens that form normally. Siri parameters prefill a request; they do not bypass the app's write path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the extension reproducible with Expo prebuild
&lt;/h2&gt;

&lt;p&gt;The native build is reproducible too. This project regenerates its ignored iOS directory through Expo prebuild. A target added by hand in Xcode would not survive clean generation, so &lt;code&gt;plugins/with-widgets.js&lt;/code&gt; creates and configures the extension, links the Swift files and resources, declares the App Group, and describes the second target to EAS for signing. This follows Expo's &lt;a href="https://docs.expo.dev/config-plugins/introduction/" rel="noopener noreferrer"&gt;config plugin model&lt;/a&gt; for changes that must survive native regeneration.&lt;/p&gt;

&lt;p&gt;One build setting in that plugin deserves a mention: &lt;code&gt;ENABLE_DEBUG_DYLIB = NO&lt;/code&gt; for the extension. The implementation records a failure where App Intents metadata extraction inspected the debug stub's dependencies, did not find AppIntents, and skipped extraction without failing the build. Configurable widgets then had no usable intent and displayed an empty state despite the signed-in app. The setting makes the debug extension use the binary layout expected by that metadata path.&lt;/p&gt;

&lt;p&gt;The tests check the pure snapshot builder, the Swift decoder's required fields, deep links, localization tables, shared colors, fonts, and native configuration. These are targeted contract checks, not a generated cross-language schema or a replacement for running the extension on a device. They catch several ways two separately compiled consumers can drift before that drift reaches the home screen.&lt;/p&gt;

&lt;p&gt;This is the boundary I would reuse for another React Native product: let the app own the meaning of its data, and send the extension a bounded, versioned description of what it can display. Leave enough date structure for the display to age, and make the age of every other answer an explicit consequence of the last write.&lt;/p&gt;

&lt;p&gt;These four widgets are implemented in &lt;a href="https://apps.apple.com/app/id6803634188" rel="noopener noreferrer"&gt;OdoKeep for iOS&lt;/a&gt;. If you track a vehicle, they are a compact way to see its deadlines and next service without opening the full &lt;a href="https://www.odokeep.com" rel="noopener noreferrer"&gt;logbook&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>swift</category>
      <category>ios</category>
      <category>expo</category>
    </item>
    <item>
      <title>Replacing Geospatial Database Reads with Static JSON Tiles and Cloudflare</title>
      <dc:creator>Rafael Soares</dc:creator>
      <pubDate>Fri, 09 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/rsoaresdev/replacing-geospatial-database-reads-with-static-json-tiles-and-cloudflare-24j0</link>
      <guid>https://dev.to/rsoaresdev/replacing-geospatial-database-reads-with-static-json-tiles-and-cloudflare-24j0</guid>
      <description>&lt;p&gt;Every OdoKeep user looking for nearby fuel prices was asking a different question against the same data.&lt;/p&gt;

&lt;p&gt;The location changed. The radius changed. The selected fuel changed. The underlying station list was shared by everyone and rebuilt on a daily schedule.&lt;/p&gt;

&lt;p&gt;That was enough to reconsider where the query happened.&lt;/p&gt;

&lt;p&gt;OdoKeep is a React Native vehicle logbook. Its fuel-price feature consumes national feeds from Portugal, Spain, France, Italy, and Austria. The tile module documents a working set of roughly 46,000 stations; the count changes with the feeds. Originally, the app asked the backend for nearby stations and country-level brand counts.&lt;/p&gt;

&lt;p&gt;The current price-serving path reads JSON files from Cloudflare R2 through a public custom domain. Supabase still handles the private garage data. The publishing job also reads source attribution metadata and reports its health. What went away was the need for the database to answer each phone's fuel-price lookup.&lt;/p&gt;

&lt;p&gt;I could have published one country file and let the app filter it. That would be easy to build, but a nearby lookup should not require downloading a country's entire station list. The shared dataset needed a partition that matched the way people use the screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  One grid module for the publisher and the phone
&lt;/h2&gt;

&lt;p&gt;The partition is a half-degree grid. &lt;code&gt;lib/fuel-prices/tiles.ts&lt;/code&gt; contains the grid arithmetic used by both the publisher and the client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TILE_DEGREES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tileKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lat&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;lon&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="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;TILE_DEGREES&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lon&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;TILE_DEGREES&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lisbon at &lt;code&gt;38.72, -9.14&lt;/code&gt; lands in &lt;code&gt;77_-19&lt;/code&gt;. The negative coordinate makes &lt;code&gt;Math.floor&lt;/code&gt; significant: truncation would put points west of Greenwich in the wrong cell.&lt;/p&gt;

&lt;p&gt;The published layout is small enough to describe without an API specification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fuel/
  index.json
  sources.json
  brands/
    PT.json
    ES.json
    ...
  tiles/
    77_-19.json
    77_-18.json
    ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A tile contains station facts: coordinates, identity, address information, prices, opening hours when supplied, and source update times. It does not contain distance from the user. Distance belongs to a lookup, not to a station.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fetch a bounding box, then filter by distance
&lt;/h2&gt;

&lt;p&gt;For a search, the client first computes the cells covering the circle's bounding box. The approximate latitude span comes from the radius divided by kilometers per degree. The longitude span is widened by the cosine of latitude, because longitude degrees become narrower farther north.&lt;/p&gt;

&lt;p&gt;Fetching a bounding box is intentionally a little imprecise. A corner tile can be requested even if none of its stations fall inside the circle. After loading, the app uses haversine distance to discard stations outside the requested radius and sort the rest. It keeps the same Earth radius as the query this replaced.&lt;/p&gt;

&lt;p&gt;The file count is where the grid size earns its place. A half-degree cell is roughly 56 km tall, with a narrower width at the latitudes served. The default 10 km radius means a 20 km diameter. The tests sample the documented coverage region and assert at most four cells for that default and at most nine for the 30 km radius ceiling.&lt;/p&gt;

&lt;p&gt;The diameter is easy to forget when estimating this. A statement such as "a 30 km search fits in a 56 km cell" sounds reasonable until you draw the circle.&lt;/p&gt;

&lt;p&gt;A coarser grid would reduce the maximum number of requests but enlarge the files needed for the common search. The chosen grid spends more requests on a large radius so that a small radius can use smaller pieces.&lt;/p&gt;

&lt;p&gt;The index makes the empty parts cheap. It lists only cells with stations and carries the grid size and build timestamp. A coastal search can eliminate known empty cells before requesting them. The client rejects an index built on a different grid rather than using keys with the wrong meaning.&lt;/p&gt;

&lt;p&gt;Brand counts are also built once per country. Changing the brand filter no longer requires a country-wide aggregation in the database.&lt;/p&gt;

&lt;p&gt;Once those files are loaded, changing fuel, brand, or radius can reuse the held stations. The network no longer has to participate in every adjustment to the view.&lt;/p&gt;

&lt;h2&gt;
  
  
  Empty and failed are different outcomes
&lt;/h2&gt;

&lt;p&gt;The actual tile cache trusts entries for six hours. It holds parsed data in memory, persists copies for later launches, and keeps one in-flight promise per key. Two consumers asking for a tile simultaneously share the request. When a usable old copy exists and the network fails, that copy can still serve the lookup.&lt;/p&gt;

&lt;p&gt;The most important cache change was giving tile retrieval three outcomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;TileOutcome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;stations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TileStation&lt;/span&gt;&lt;span class="p"&gt;[]&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;empty&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty cell and a cell I could not fetch are different evidence.&lt;/p&gt;

&lt;p&gt;Suppose a lookup crosses two populated cells. One returns normally and the other returns a 503 with no usable cached copy. Combining the first cell's stations into a result would silently shrink the searched area. That shorter list could then be sorted and cached as if it answered the whole question.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stationsAround&lt;/code&gt; returns failure when any required tile fails. The higher layer can fall back to its previous search cache or explain that it could not retrieve the data. It does not turn a missing piece of the map into a newly complete result.&lt;/p&gt;

&lt;p&gt;The current handling of a 404 is more limited: it is treated as an empty outcome and remembered for five minutes. That short lifetime matters during publication, when a cell can be temporarily unavailable. The index avoids many such requests, but a 404 still does not give the same certainty as a versioned, internally consistent dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publish complete datasets, tiles before index
&lt;/h2&gt;

&lt;p&gt;Publication has a corresponding completeness rule. The TypeScript builder requires every configured country and refuses to publish when any adapter fails. Otherwise, an upstream outage in one country would become a new station set in which that country had disappeared. Keeping the previous full build is preferable to publishing that omission as fresh data.&lt;/p&gt;

&lt;p&gt;The scheduled GitHub Action runs the builder with Bun. The sync path uses built-in modules and project source files, so this job does not install the mobile app's dependency tree. Its output is a directory; uploading it is a separate workflow step.&lt;/p&gt;

&lt;p&gt;The JSON files are gzipped before upload and served with the appropriate content encoding. &lt;code&gt;gzip -n&lt;/code&gt; leaves the timestamp out of the compressed header, so identical input does not acquire different bytes merely because it was compressed on another day. The objects carry a one-hour public cache directive.&lt;/p&gt;

&lt;p&gt;Tiles are uploaded before the index. Publishing the index first would advertise new keys that a failed upload might never deliver.&lt;/p&gt;

&lt;p&gt;This ordering does not make the deployment atomic. Files are replaced in place, and a client can see a mixture of builds. Pruning can also remove an old tile while a client still holds an earlier index. For current fuel-price browsing, the implementation accepts that limited consistency and uses short-lived missing-tile handling. A dataset requiring a consistent snapshot would need generation-specific paths and a pointer switched only after all files were available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this architecture fits
&lt;/h2&gt;

&lt;p&gt;R2 is useful here because its published pricing has no internet egress bandwidth charge. Storage and operations are still part of the &lt;a href="https://developers.cloudflare.com/r2/pricing/" rel="noopener noreferrer"&gt;R2 pricing model&lt;/a&gt;, and cache configuration still matters. A public custom domain can use Cloudflare caching, as described in the &lt;a href="https://developers.cloudflare.com/r2/buckets/public-buckets/" rel="noopener noreferrer"&gt;public bucket documentation&lt;/a&gt;. I do not have a before-and-after bill to attach to this article; the concrete change is that price lookups no longer consume database reads or database egress.&lt;/p&gt;

&lt;p&gt;Daily publication also does not make every station's price current to the minute. The source update time is retained separately, and attribution travels with the dataset. The national feeds have different update behavior and reuse conditions. Serving a file rather than a row does not change either.&lt;/p&gt;

&lt;p&gt;This approach fits because the dataset is public, shared, geographically bounded, and updated much less often than it is read. I would make a different choice for private data, per-user authorization, or live prices whose freshness depended on each transaction.&lt;/p&gt;

&lt;p&gt;For this feature, the phone already knew the location, radius, and filters. Giving it a reusable local slice let it finish the query without repeatedly asking the server to rediscover the same stations.&lt;/p&gt;

&lt;p&gt;You can see the resulting fuel-price browser in &lt;a href="https://www.odokeep.com" rel="noopener noreferrer"&gt;OdoKeep&lt;/a&gt;, alongside the vehicle logbook it supports. The &lt;a href="https://apps.apple.com/app/id6803634188" rel="noopener noreferrer"&gt;iOS app&lt;/a&gt; is available if you want to try it around your own route.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>geospatial</category>
      <category>typescript</category>
      <category>reactnative</category>
    </item>
    <item>
      <title>Offline Receipt OCR in Expo: The Hard Part Starts After Text Recognition</title>
      <dc:creator>Rafael Soares</dc:creator>
      <pubDate>Fri, 02 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/rsoaresdev/offline-receipt-ocr-in-expo-the-hard-part-starts-after-text-recognition-2eml</link>
      <guid>https://dev.to/rsoaresdev/offline-receipt-ocr-in-expo-the-hard-part-starts-after-text-recognition-2eml</guid>
      <description>&lt;p&gt;On a fuel receipt, &lt;code&gt;1,899&lt;/code&gt; might be a unit price. &lt;code&gt;95&lt;/code&gt; might be a product grade. &lt;code&gt;04&lt;/code&gt; might be a pump number. The OCR engine can read all three perfectly and still leave me with the wrong fuel record.&lt;/p&gt;

&lt;p&gt;I added receipt scanning to OdoKeep, my vehicle logbook built with React Native and Expo. Recognition runs on the phone: Apple's Vision framework on iOS and bundled Google ML Kit text recognizers on Android. The photograph and recognized text are not uploaded by this feature.&lt;/p&gt;

&lt;p&gt;The native module handles acquisition, image preparation, text recognition, and the temporary photograph. Interpreting the result lives in TypeScript under &lt;code&gt;lib/receipt-scan&lt;/code&gt;. Keeping that boundary lets the parser run in ordinary unit tests, independent of a camera or a particular phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the evidence attached to the text
&lt;/h2&gt;

&lt;p&gt;The native result is richer than a string. It contains lines, source boxes, available confidence information, and, where the recognizer exposes them, alternative transcriptions. The parser uses those positions to connect a field back to the place it came from on the photograph.&lt;/p&gt;

&lt;p&gt;The pipeline is roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Photograph
  -&amp;gt; native OCR lines and boxes
  -&amp;gt; numeric tokens with units and source positions
  -&amp;gt; ranked field candidates
  -&amp;gt; arithmetic corroboration
  -&amp;gt; reviewed draft
  -&amp;gt; original fuel or charging form
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no write between text recognition and the original form. A scan produces a prefill the user can correct and save through the same route they already use for manual entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rank readings before choosing a number
&lt;/h2&gt;

&lt;p&gt;Numeric normalization is the first place where the text stops being simple. Receipts can use decimal commas, decimal points, grouping spaces, Swiss apostrophes, or native digit sets. Thermal printing can also lose a separator or turn a digit into a letter.&lt;/p&gt;

&lt;p&gt;The tokenizer can retain more than one plausible reading of a number. A token such as &lt;code&gt;1,899&lt;/code&gt; does not have to be resolved before the parser has seen its label, unit, and neighbors. A possible repair has a lower standing than an ordinary reading, and its source location stays attached to it.&lt;/p&gt;

&lt;p&gt;Candidate ranking starts with printed evidence. An explicit quantity unit is strong evidence for quantity. A rate such as &lt;code&gt;EUR/L&lt;/code&gt; is strong evidence for unit price. A total label is evidence for the reference amount.&lt;/p&gt;

&lt;p&gt;Dates, percentages, distance readings, and administrative labels are excluded from the amount slots. An unlabelled number does not become a missing quantity because it happens to make the calculation convenient. The search keeps at most twenty candidates per slot, which bounds the combinations it considers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multiplication is corroboration
&lt;/h2&gt;

&lt;p&gt;The arithmetic seems like the easy part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;quantity * unitPrice = printedTotal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is useful, but it needs a smaller claim than "the receipt is correct". A matching product corroborates a candidate reading. It does not prove that the image was read correctly or that all three fields describe the transaction I think they describe.&lt;/p&gt;

&lt;p&gt;The parser compares the product with half a currency minor unit, plus floating point slack. For a currency with two decimal places, the base tolerance is half a cent. It does not allow a percentage of the bill, which would make larger receipts accept larger OCR errors.&lt;/p&gt;

&lt;p&gt;This example is used in the tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;31.93 L x 1.899 EUR/L
TOTAL 60.84 EUR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The product is approximately 60.635. A loose percentage tolerance could accept 60.84. The currency-based comparison rejects that arithmetic match.&lt;/p&gt;

&lt;p&gt;Another test has a perfectly readable quantity and price, a subtotal that matches their product, and a lower final paid amount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;40 L x 1.80 EUR/L
Subtotal 72 EUR
Discount 4 EUR
Total 68 EUR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parser retains 68 as the printed total reference. It does not promote 72 to the final total just because 72 makes the multiplication work. Quantity and unit price can still be useful even when the final amount includes a discount.&lt;/p&gt;

&lt;p&gt;OdoKeep calculates the saved cost from quantity multiplied by unit price, minus an optional reviewed discount. The printed total is not copied into the saved cost. It also cannot supply a missing quantity or a missing unit price through division.&lt;/p&gt;

&lt;p&gt;If only the total was recognized, the user still needs to enter the amounts. If quantity was recognized and price was not, the prefill leaves price empty. A partial result is allowed to remain partial.&lt;/p&gt;

&lt;p&gt;For scans that do not pass the arithmetic check, the app can request another reading with higher contrast. The two passes are compared by arithmetic corroboration and then by the amount fields they recovered. A tie keeps the original pass. Date, time, station, and other non-amount fields can fill gaps from the losing pass because both readings refer to the same photograph.&lt;/p&gt;

&lt;p&gt;Even that better reading goes through review. More evidence changes the ranking; it does not remove the user's opportunity to correct it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Units and currencies need explicit choices
&lt;/h2&gt;

&lt;p&gt;Units create their own class of believable errors. Liters, US gallons, imperial gallons, kilograms, cubic meters, and kilowatt-hours are all valid in the app's fuel or charging domain. They are not interchangeable.&lt;/p&gt;

&lt;p&gt;A receipt that prints only &lt;code&gt;gal&lt;/code&gt; cannot settle which gallon it means. The importer asks the user. If a compatible liquid unit is converted, quantity is multiplied by the volume factor and unit price is divided by the same factor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;convertedQuantity = printedQuantity * factor
convertedUnitPrice = printedUnitPrice / factor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This preserves their product before input rounding. The importer rounds converted inputs to at most three decimal places, so the original form's recalculated cost can differ slightly from the printed reference. Keeping the printed unit avoids that conversion rounding.&lt;/p&gt;

&lt;p&gt;Physical dimension conflicts are handled more cautiously. A quantity printed in liters and a price explicitly printed per kilowatt-hour cannot be combined into a fuel cost. The parser retains the quantity and its unit and leaves price empty. It does not reinterpret one dimension as another.&lt;/p&gt;

&lt;p&gt;Currency is equally explicit. If the receipt names a different currency, the user can keep it. If they choose the configured currency instead, the importer keeps quantity and clears unit price. No exchange rate is assumed during that choice. Displaying a saved foreign-currency cost later is a separate responsibility of the app's dated conversion layer.&lt;/p&gt;

&lt;p&gt;Regional context comes from the device region independently of the interface language. Someone using an English interface in Portugal should not have every receipt interpreted as American. Explicit currency codes and units carry stronger evidence than regional defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recognition and photograph lifecycle
&lt;/h2&gt;

&lt;p&gt;On iOS, Vision uses accurate recognition with language correction disabled. That is a deliberate setting for this input: numeric quantities and abbreviated product lines do not benefit from every assumption that helps a language model repair prose. Supported language hints are checked against the configured Vision request before being supplied.&lt;/p&gt;

&lt;p&gt;On Android, the app bundles the Latin, Chinese, Devanagari, Japanese, and Korean text recognizers described in the &lt;a href="https://developers.google.com/ml-kit/vision/text-recognition/v2/android" rel="noopener noreferrer"&gt;ML Kit text recognition documentation&lt;/a&gt;. Bundling increases binary size and makes those recognizers available offline. The document-scanner acquisition path has separate Google Play services requirements and may need an initial download; the picker and bundled OCR provide a fallback. This does not establish support for every writing system.&lt;/p&gt;

&lt;p&gt;Image handling matters just as much as field ranking. Orientation is normalized into the pixels, and the retained OCR boxes must agree with the displayed photograph. Both native implementations can try rotations when the initial reading is sparse, but a few extra fragments are not enough to justify turning the image.&lt;/p&gt;

&lt;p&gt;Temporary copies belong to a scan session. A cancelled session cannot write a late photograph after cleanup, and one review cannot remove another review's image. A draft also belongs to the form that requested it and can be consumed once. Returning to that form again must not overwrite the user's corrections with the old scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test interpretation separately from recognition
&lt;/h2&gt;

&lt;p&gt;The parser tests include two real receipt transcriptions and synthetic cases for regional formats, ambiguous units, discounts, bad dates, alternate readings, and partial handoff. Those tests check what the parser does with OCR text. They are not a measured recognition rate on an image corpus. Low light, faded print, and script coverage still need device-level evaluation.&lt;/p&gt;

&lt;p&gt;That separation makes failures easier to investigate. If the characters are wrong, I look at acquisition and recognition. If the characters are right but the record is wrong, I look at the parser's evidence and the handoff. A complete-looking JSON object would hide the difference.&lt;/p&gt;

&lt;p&gt;Receipt scanning is part of &lt;a href="https://www.odokeep.com" rel="noopener noreferrer"&gt;OdoKeep&lt;/a&gt;. If you keep fuel or charging records, you can try the workflow in the &lt;a href="https://apps.apple.com/app/id6803634188" rel="noopener noreferrer"&gt;iOS app&lt;/a&gt;: photograph the receipt, inspect what was recovered, and finish in the ordinary form.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>typescript</category>
      <category>ocr</category>
    </item>
    <item>
      <title>Keeping Offline Edits Safe in React Native with MMKV and Supabase</title>
      <dc:creator>Rafael Soares</dc:creator>
      <pubDate>Fri, 25 Sep 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/rsoaresdev/keeping-offline-edits-safe-in-react-native-with-mmkv-and-supabase-1a1d</link>
      <guid>https://dev.to/rsoaresdev/keeping-offline-edits-safe-in-react-native-with-mmkv-and-supabase-1a1d</guid>
      <description>&lt;p&gt;A record that comes back after you delete it is a good way to make someone stop trusting a logbook.&lt;/p&gt;

&lt;p&gt;That was one of the failure cases I had to address in OdoKeep, my vehicle tracking app. A user could create a record, delete it while the insert was still traveling, and see it again on the next sync. The local delete worked. The server insert worked. The queue's explanation of what had happened was wrong.&lt;/p&gt;

&lt;p&gt;OdoKeep uses React Native and Expo, MMKV for local persistence, and Supabase for the server copy. The repositories write locally and enqueue the operation. Screens can use the local data while the sync manager deals with the network.&lt;/p&gt;

&lt;p&gt;That makes saving a fill-up possible without a connection. It also means the queue has to describe more than a list of requests to try later.&lt;/p&gt;

&lt;h2&gt;
  
  
  A queued create is different from a traveling create
&lt;/h2&gt;

&lt;p&gt;The first useful distinction was between an operation that had not been sent and one that had been sent but had not returned.&lt;/p&gt;

&lt;p&gt;Consider an ordinary offline session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create record locally
Queue INSERT
Delete record locally
Cancel queued INSERT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no reason to contact the server. The record never existed there. Folding the create and delete into nothing is the correct optimization.&lt;/p&gt;

&lt;p&gt;Now put a slow connection between the second and third steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create record locally
Send INSERT
Delete record locally
Cancel queued INSERT
Server finishes INSERT
Next pull returns the record
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Removing an operation from an array cannot recall a request already on the wire. The queue still called the create "pending", and the delete treated that word as proof that the server had never seen it.&lt;/p&gt;

&lt;p&gt;The fix is a set of operation IDs currently in flight. In &lt;code&gt;lib/sync-queue.ts&lt;/code&gt;, the part of coalescing that looks for a cancellable create now includes this condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pendingCreate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;op&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;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;create&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="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;inFlight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;op&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that create has already been sent, deleting the record must enqueue a server delete. It can no longer cancel the pair locally.&lt;/p&gt;

&lt;p&gt;The set is deliberately held in memory. After a restart, the process has no live request to track. Persisting an &lt;code&gt;inFlight&lt;/code&gt; flag would leave the next launch believing that an operation was still being sent, with nothing left to clear it. The durable queue survives; the description of this process's active requests does not.&lt;/p&gt;

&lt;p&gt;This set also keeps an operation out of a second flush while the first request is outstanding. A sync triggered by reconnecting and another triggered by a refresh gesture must not send the same insert concurrently. The manager holds a guard around the flush, and the queue independently excludes in-flight operations from its due list. Even a user request that skips backoff still respects that exclusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recovering an insert when the response disappears
&lt;/h2&gt;

&lt;p&gt;There is another awkward case after an insert: the server may commit the row and the phone may lose the response.&lt;/p&gt;

&lt;p&gt;On the next attempt, a plain insert can fail because the ID already exists. Treating that as a permanent rejection would roll back a local record that the server had successfully saved.&lt;/p&gt;

&lt;p&gt;For the ordinary create path, the sync manager asks PostgREST to ignore a duplicate ID rather than overwrite it. If that produces no returned row, it reads the ID back. A readable row is confirmation that the create landed, and the device adopts it. If that confirmation request fails, the operation stays retryable. An unreadable collision is not accepted as success.&lt;/p&gt;

&lt;p&gt;This is not an exactly-once delivery system. It is a retry path that can recover evidence of an insert without replacing an existing row's contents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updating the version you actually edited
&lt;/h2&gt;

&lt;p&gt;Sharing a vehicle introduced a different problem. Two people can edit a record based on the same server version, while one or both are offline.&lt;/p&gt;

&lt;p&gt;An update filtered only by ID gives the last sync permission to erase the earlier writer's changes. Both requests can succeed. Neither person learns that their edit lost.&lt;/p&gt;

&lt;p&gt;Queued updates therefore carry &lt;code&gt;baseUpdatedAt&lt;/code&gt;: the server timestamp the device knew before the local edit. The essential write condition looks like this, simplified from &lt;code&gt;lib/sync-manager.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;write&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;client&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;tableName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;operation&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;baseUpdatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;write&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;updated_at&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;baseUpdatedAt&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;result&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;write&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="nx"&gt;returningColumns&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The update can land only while the row still has the version it was edited against. The &lt;a href="https://supabase.com/docs/reference/javascript/update" rel="noopener noreferrer"&gt;Supabase update API&lt;/a&gt; supports returning the affected rows through &lt;code&gt;.select()&lt;/code&gt;, which makes an empty match observable to the client.&lt;/p&gt;

&lt;p&gt;Capturing the base is easy to get subtly wrong. After the first local edit, the device's row may contain a locally generated timestamp. A second edit must not replace the queued operation's base with that value. The server has never issued it. Coalescing keeps the original server base and merges the payload changes.&lt;/p&gt;

&lt;p&gt;A newly created row is another exception. Until the server confirms the create, its local timestamp cannot serve as a server precondition. The queue distinguishes "a create is still recorded" from "a create is still cancellable" for exactly this reason.&lt;/p&gt;

&lt;p&gt;An empty update result also needs interpretation. It might mean another writer changed the row. It might mean the row was deleted. On a shared vehicle, it might mean the user's access was revoked or their role changed.&lt;/p&gt;

&lt;p&gt;The conflict branch checks whether a readable row exists with a newer server timestamp. Only that evidence produces the stale-write message. The operation goes through the permanent refusal path, the user is told, and the device reconciles with the server. Retrying the rejected payload without the original precondition would recreate the overwrite this check was introduced to prevent.&lt;/p&gt;

&lt;p&gt;The current policy is straightforward: the server copy wins a confirmed conflict. There is no field-level merge or conflict editor. An operation from an older build that has no base also cannot receive this protection retroactively. Those are useful limits to state when describing what the system guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pull with a server watermark
&lt;/h2&gt;

&lt;p&gt;The pull side needs the same care about timestamps. A phone can be two minutes fast. If it saves its own current time as the delta watermark, it asks the next pull to skip changes through a point in the server's future.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lib/delta-watermark.ts&lt;/code&gt; advances the watermark from the timestamps in returned rows instead. An empty response retains the previous watermark. The selected server timestamp is kept in its original string form for the next exclusive comparison, rather than being reformatted through the phone's date serializer.&lt;/p&gt;

&lt;p&gt;The phone's clock is useful for retry delays. It cannot certify which server changes the phone has observed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry budgets and regression tests
&lt;/h2&gt;

&lt;p&gt;Failures also have to preserve the difference between "no" and "not now". The queue records total failed attempts separately from failures that count toward its retry limit. Transport failures and known temporary server failures drive capped exponential backoff without spending the permanent failure allowance. A brief service interruption should not remove a locally created vehicle and all the records queued against it.&lt;/p&gt;

&lt;p&gt;The regression tests exercise these decisions directly: cancelling an unsent create, keeping a delete after an in-flight create, withholding work from a second flush, recovering a lost insert response, preserving an update's original base, and distinguishing a conflict from missing access. The server and storage are mocked in these tests. They verify the client's decisions, not the deployed policies or a real mobile network.&lt;/p&gt;

&lt;p&gt;None of these fixes changed the fuel form. They changed the evidence the sync layer requires before it calls a write cancelled, confirmed, or permanently refused.&lt;/p&gt;

&lt;p&gt;If you are building an offline queue, the create-then-delete test is worth adding early. Hold the insert response open, delete the local row, and inspect what remains queued. It exposes an assumption that an ordinary airplane-mode test never reaches.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://www.odokeep.com" rel="noopener noreferrer"&gt;OdoKeep&lt;/a&gt; for people who want to keep their vehicle history without making connectivity a requirement for every entry. These are some of the less visible decisions behind that promise. The app is available on the &lt;a href="https://apps.apple.com/app/id6803634188" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>supabase</category>
      <category>typescript</category>
      <category>offlinefirst</category>
    </item>
  </channel>
</rss>
