<?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: André Marinho</title>
    <description>The latest articles on DEV Community by André Marinho (@andrelmarinho).</description>
    <link>https://dev.to/andrelmarinho</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%2F4058040%2Ff8657457-c6f1-4c54-8d7e-eff8ba0fb3fc.jpg</url>
      <title>DEV Community: André Marinho</title>
      <link>https://dev.to/andrelmarinho</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andrelmarinho"/>
    <language>en</language>
    <item>
      <title>How an Excel Report Got Me Into Programming</title>
      <dc:creator>André Marinho</dc:creator>
      <pubDate>Thu, 22 Oct 2026 15:34:23 +0000</pubDate>
      <link>https://dev.to/andrelmarinho/how-an-excel-report-got-me-into-programming-3ffi</link>
      <guid>https://dev.to/andrelmarinho/how-an-excel-report-got-me-into-programming-3ffi</guid>
      <description>&lt;p&gt;At my first job, I watched my manager spend about two hours a day transferring information from our ERP into Excel. That’s how I started learning to code.&lt;/p&gt;

&lt;p&gt;I worked in finance. With her approval, I asked the ERP vendor for a quote to automate the report. The price was more than the company would approve.&lt;/p&gt;

&lt;p&gt;So I asked if I could try connecting the database to Excel myself. She agreed, provided I was careful not to delete anything.&lt;/p&gt;

&lt;p&gt;I had absolutely no idea how to do it. I just believed it was possible.&lt;/p&gt;

&lt;p&gt;First, I had to learn how to query a database. Then I discovered how many tables could sit behind a single expense screen.&lt;/p&gt;

&lt;p&gt;The ERP supported different business models. Understanding the relationships between tables was only part of the job. I also had to understand how our operation used them and which records belonged in the report.&lt;/p&gt;

&lt;p&gt;Then came filtering the data before bringing it into Excel. Pulling everything would have made the spreadsheet unusable.&lt;/p&gt;

&lt;p&gt;As far as I remember, it took almost a year to get it working. The spreadsheet could finally read from the source, and those two hours of daily manual work were gone.&lt;/p&gt;

&lt;p&gt;I understood the problem from working with the information myself. Learning to build the solution came afterward.&lt;/p&gt;

&lt;p&gt;That sequence still shapes how I work today. Understanding the operation helps me decide what is worth building.&lt;/p&gt;

&lt;p&gt;And that’s why I love building software so much. You can build almost anything. What was the first problem that pushed you to learn something technical?&lt;/p&gt;

</description>
      <category>career</category>
      <category>automation</category>
      <category>programming</category>
      <category>discuss</category>
    </item>
    <item>
      <title>A shared demo account without a reset scheduler</title>
      <dc:creator>André Marinho</dc:creator>
      <pubDate>Thu, 15 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/andrelmarinho/a-shared-demo-account-without-a-reset-scheduler-1383</link>
      <guid>https://dev.to/andrelmarinho/a-shared-demo-account-without-a-reset-scheduler-1383</guid>
      <description>&lt;p&gt;The demo in &lt;a href="https://andremarinho.me/projects/turistar" rel="noopener noreferrer"&gt;Turistar&lt;/a&gt; signs visitors into a real, shared account. They can move activities, edit a budget, and delete a trip through the same authenticated screens and operations I use for my own travel planning.&lt;/p&gt;

&lt;p&gt;The next visitor needs useful examples even if an earlier visitor deleted them. I keep a saved baseline and request a restore when the demo account enters its dashboard, provided at least an hour has passed since the previous reset. There is no background scheduler.&lt;/p&gt;

&lt;p&gt;You can &lt;a href="https://turistar.me/login" rel="noopener noreferrer"&gt;open the login page and use its demo button&lt;/a&gt;. The account is shared, so changes are visible to other visitors and are temporary.&lt;/p&gt;

&lt;h2&gt;
  
  
  One account uses the normal product
&lt;/h2&gt;

&lt;p&gt;The login and signup screens already have a demo entry point. Their sign-in helper authenticates the shared account through the application's auth flow. After login, the dashboard recognizes that account and calls &lt;code&gt;resetDemoIfStale&lt;/code&gt; before fetching its data.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/andre-lmarinho/turistar/blob/d442fc13e62347d8e49a59e1173f6c8b7298288f/src/features/demo/README.md" rel="noopener noreferrer"&gt;demo feature documentation&lt;/a&gt; describes that path. A visitor uses the existing permissions, itinerary editor, and budget operations. I don't have a second implementation of those features to keep in step with the product.&lt;/p&gt;

&lt;p&gt;The tradeoff is shared state. Two visitors can change the same itinerary or see each other's edits. Serializing the resets prevents two restore operations from competing; it does not give each visitor a private sandbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Entry requests a check; PostgreSQL decides whether to restore
&lt;/h2&gt;

&lt;p&gt;The server helper makes one repository call, which invokes &lt;code&gt;maybe_reset_demo&lt;/code&gt;. Calling the function on entry does not mean restoring the data on every entry. The database owns the interval check and records the last successful reset.&lt;/p&gt;

&lt;p&gt;These statements from the &lt;a href="https://github.com/andre-lmarinho/turistar/blob/d442fc13e62347d8e49a59e1173f6c8b7298288f/supabase/migrations/20261014095433_restore_demo_examples.sql" rel="noopener noreferrer"&gt;restoration function&lt;/a&gt; show the synchronization point and the time rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_reset_at&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;last_reset&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demo_reset_state&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last_reset&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 hour'&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The row lock holds competing resets behind the same bookkeeping row. Once the first restore completes, the next caller sees its updated timestamp and returns without repeating the work. The function also locks existing example snapshots in the same order as the itinerary writer, before restoring the plans.&lt;/p&gt;

&lt;p&gt;For example, if the last reset ran at 10:00, an entry at 10:30 leaves the baseline untouched and an entry at 11:05 can restore it. With no later dashboard entry, no restore runs. The hour is a minimum interval since the last reset.&lt;/p&gt;

&lt;p&gt;There is no dirty-check. After the interval expires, the function can restore the baseline even if no visitor changed it. Eligibility depends on the timestamp, without comparing the current data to the baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restore the relationships that make an example usable
&lt;/h2&gt;

&lt;p&gt;Restoring just the itinerary JSON would leave several ways to break the demo. A deleted plan needs its original ID and public slug back so an existing URL works again. Its metadata, owner membership, expenses, and snapshot must agree with the restored trip.&lt;/p&gt;

&lt;p&gt;The baseline therefore includes plan metadata, the itinerary snapshot, and budget entries. The reset recreates deleted examples, replaces edited example data, restores membership, clears their old event history, and removes extra plans created under the demo account. It also restores the demo profile. A baseline plan that now belongs to a different account causes the function to fail rather than take ownership of that plan.&lt;/p&gt;

&lt;p&gt;Dates need maintenance too. Rome starts yesterday and Boipeba starts in 30 days, relative to &lt;code&gt;America/Bahia&lt;/code&gt;. The restore shifts both plan dates and itinerary day labels while preserving activity IDs and content. A prepared trip stays useful for exploring ongoing and upcoming travel without someone periodically editing its calendar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protect the baseline from the account it repairs
&lt;/h2&gt;

&lt;p&gt;The demo account can edit product data, but it cannot edit the saved baseline or reset bookkeeping. Those tables have Row-Level Security enabled, and direct privileges are revoked from visitor roles.&lt;/p&gt;

&lt;p&gt;The restore is a &lt;code&gt;security definer&lt;/code&gt; function with an empty &lt;code&gt;search_path&lt;/code&gt; and qualified table names. It checks the caller inside the function: the demo identity can restore itself, and the defined database maintenance roles can run it during setup. Another authenticated account cannot trigger a demo reset merely by calling the RPC.&lt;/p&gt;

&lt;p&gt;A visitor with write access to maintenance data could replace the examples or move the timestamp that decides when they return.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dashboard stays available when reset fails
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/andre-lmarinho/turistar/blob/d442fc13e62347d8e49a59e1173f6c8b7298288f/src/features/demo/lib/resetDemoIfStale.ts" rel="noopener noreferrer"&gt;server helper catches reset failures&lt;/a&gt;, allowing the dashboard to continue loading. Restoration is best-effort. An already-open tab is not refreshed automatically, and a failed reset can leave the examples altered until a later entry succeeds.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/andre-lmarinho/turistar/blob/d442fc13e62347d8e49a59e1173f6c8b7298288f/src/features/demo/repositories/DemoRepository.integration-test.ts" rel="noopener noreferrer"&gt;integration tests&lt;/a&gt; exercise restoration against a local database, including deleted examples and their URLs, date shifting, access boundaries, and repeated or concurrent reset calls.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>authentication</category>
      <category>productengineering</category>
    </item>
    <item>
      <title>I built an open-source travel planner that I actually use</title>
      <dc:creator>André Marinho</dc:creator>
      <pubDate>Thu, 03 Sep 2026 20:09:54 +0000</pubDate>
      <link>https://dev.to/andrelmarinho/i-built-an-open-source-travel-planner-that-i-actually-use-4i27</link>
      <guid>https://dev.to/andrelmarinho/i-built-an-open-source-travel-planner-that-i-actually-use-4i27</guid>
      <description>&lt;p&gt;Every time I planned a trip, information ended up scattered across Google Maps, notes, messages, and spreadsheets.&lt;/p&gt;

&lt;p&gt;So I built Turistar.&lt;/p&gt;

&lt;p&gt;It lets you organize a trip by day, move activities with drag and drop, and keep the budget alongside the itinerary.&lt;/p&gt;

&lt;p&gt;I originally built it to help myself. Since it works for me, I decided to make it public. Maybe it can help someone else too.&lt;/p&gt;

&lt;p&gt;The project uses Next.js, TypeScript, Supabase, and tRPC. It is also open source, so you can explore the code or contribute.&lt;/p&gt;

&lt;p&gt;Try it:&lt;br&gt;
&lt;a href="https://turistar.me" rel="noopener noreferrer"&gt;https://turistar.me&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Source code:&lt;br&gt;
&lt;a href="https://github.com/andre-lmarinho/turistar" rel="noopener noreferrer"&gt;https://github.com/andre-lmarinho/turistar&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’m still improving it, so feedback about the product or the code is welcome.&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>showdev</category>
      <category>opensource</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Removing the login-to-dashboard waterfall in Next.js</title>
      <dc:creator>André Marinho</dc:creator>
      <pubDate>Tue, 11 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/andrelmarinho/removing-the-login-to-dashboard-waterfall-in-nextjs-7j6</link>
      <guid>https://dev.to/andrelmarinho/removing-the-login-to-dashboard-waterfall-in-nextjs-7j6</guid>
      <description>&lt;p&gt;The slow part of entering &lt;a href="https://andremarinho.me/projects/lawflow" rel="noopener noreferrer"&gt;LawFlow&lt;/a&gt; crossed several systems. After authentication, the browser hydrated its session, resolved the workspace, checked billing access, loaded shared workspace data and finally mounted screens that could request their own data.&lt;/p&gt;

&lt;p&gt;The user had to wait for a chain of dependent browser requests. Optimizing a query helped its own stage while leaving the rest of that chain intact.&lt;/p&gt;

&lt;p&gt;I changed the authenticated entry route to resolve an authorized bootstrap on the server and pass the result into the client providers. The browser could initialize from that snapshot instead of discovering the workspace and rebuilding the shared state after mounting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the dependencies across providers
&lt;/h2&gt;

&lt;p&gt;The loading path had grown as authentication, multi-tenancy and billing were added. Session polling delayed workspace resolution. A billing gate controlled whether parts of the application mounted, so their data requests started only after that gate finished. Other paths repeated workspace or authorization lookups through separate calls.&lt;/p&gt;

&lt;p&gt;My first changes stayed within that structure. I replaced session polling with explicit session hydration, let independent billing and bootstrap work run in parallel, and made a detailed subscription-status request conditional on denied access. A user already allowed by the entitlement check did not need that extra request before entering.&lt;/p&gt;

&lt;p&gt;The intermediate flow still took approximately 5.7 to 6 seconds in production. The request map at that stage contained about ten authentication or database operations on the successful path, even though some were parallel or shared an HTTP batch. Counting HTTP requests alone hid work performed inside each request.&lt;/p&gt;

&lt;p&gt;The remaining question was which information the browser actually needed to discover for itself. The server already knew the authenticated user before rendering the entry page. Resolving the same user's workspace after that page mounted added another dependency boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  One entry route produces the initial state
&lt;/h2&gt;

&lt;p&gt;The authenticated page now follows this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&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;getServerSession&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;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&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;initialBootstrap&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;resolveCrmShellData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CrmView&lt;/span&gt; &lt;span class="na"&gt;initialBootstrap&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;initialBootstrap&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resolver verifies the workspace for that authenticated user, reads the workspace context needed by billing, resolves access and then loads the authorized bootstrap data. Reusing the workspace row avoids fetching it again inside billing.&lt;/p&gt;

&lt;p&gt;The snapshot contains shared application state: the current user's profile, workspace members, office settings, field configuration, notification settings and onboarding status. Client providers initialize their state from those values. Their mount no longer starts a second bootstrap through &lt;code&gt;useEffect&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Screen-specific queries still exist. Moving the bootstrap did not require loading every lead, proposal or dashboard result before returning the page. The boundary covers the shared state required to enter the CRM.&lt;/p&gt;

&lt;p&gt;Next.js supports passing serializable data from Server Components into Client Components through props. That is the &lt;a href="https://nextjs.org/docs/app/getting-started/server-and-client-components" rel="noopener noreferrer"&gt;server/client boundary&lt;/a&gt; used here. A prop reaches the client, so the bootstrap has an explicit shape rather than passing through an unrestricted database result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Access checks determine which snapshot can exist
&lt;/h2&gt;

&lt;p&gt;Moving work to the server also made the authorization order explicit. The resolver derives the workspace from the verified caller; it does not trust a workspace selected in the browser. It loads application bootstrap data only after billing permits access.&lt;/p&gt;

&lt;p&gt;Denied access produces a recovery snapshot with the information needed to show the billing gate. Failure to check billing produces an unavailable state with a retry path. Neither branch falls through to an authorized CRM with empty defaults. A missing workspace is handled as a workspace error rather than being relabeled as a billing outage.&lt;/p&gt;

&lt;p&gt;API operations retain their own authorization checks. The snapshot reflects authorization for that render; it is not permission for later mutations. Removing repeated checks within that render did not remove the checks at the API boundary.&lt;/p&gt;

&lt;p&gt;The login action needed attention too. After it wrote the session cookies, the client refreshed its router state and navigated to &lt;code&gt;/&lt;/code&gt;. That entry request produced the bootstrap against the fresh session. Returning success from the action avoided rendering the same bootstrap inside a server-action redirect and then doing it again during navigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the complete login flow
&lt;/h2&gt;

&lt;p&gt;The original path took approximately 7,441 milliseconds from clicking Log in to the rendered CRM. After the server bootstrap changes, measurements during the implementation were approximately 760 milliseconds for that same full flow, about a 90% reduction.&lt;/p&gt;

&lt;p&gt;These are approximate end-to-end measurements from the implementation. I did not preserve the final raw traces or a reproducible set of runs. The intermediate production measurements and request map were recorded, but the final numbers should not be read as a controlled benchmark or a latency guarantee for every workspace and network condition.&lt;/p&gt;

&lt;p&gt;Tests covered the resolver's authorized, denied and unavailable branches, snapshot mapping and login-action failures.&lt;/p&gt;

&lt;p&gt;Server-side loading still has a critical path. Authentication must precede workspace resolution, and access must precede loading protected application data. The improvement came from placing those dependencies in one server-owned flow, reusing resolved context and letting the client begin with the result.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>supabase</category>
      <category>performance</category>
    </item>
    <item>
      <title>Learning to Code When AI Writes the Code</title>
      <dc:creator>André Marinho</dc:creator>
      <pubDate>Sat, 01 Aug 2026 14:35:18 +0000</pubDate>
      <link>https://dev.to/andrelmarinho/learning-to-code-when-ai-writes-the-code-16b2</link>
      <guid>https://dev.to/andrelmarinho/learning-to-code-when-ai-writes-the-code-16b2</guid>
      <description>&lt;p&gt;It works and I don't know why.&lt;/p&gt;

&lt;p&gt;That is the most dangerous sentence I can say about my own code, and it got very easy to say. I started writing software in 2025, the year AI got good enough to write most of it for me. I have never learned to code without it.&lt;/p&gt;

&lt;p&gt;Most of what I read about this is written by people who learned first and got the tool second. For them AI removed a toll booth: they already knew the roads, and now they pay less to drive them. I never paid the toll. I arrived after it was gone.&lt;/p&gt;

&lt;p&gt;That is a different situation, and it comes with a different temptation. If you started when the machine could already write the thing, skipping the part where you understand it is not a slippery slope you have to be talked into. It is the default. It is faster, it works, and nobody can tell from the outside of the software side.&lt;/p&gt;

&lt;p&gt;So the question I care about is not whether AI makes me a worse programmer. The tool is here, I use it every day, and I would be slower and poorer without it. The question is narrower: when the machine hands you working code, what separates learning something from collecting something?&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure that teaches, and the one that doesn't
&lt;/h2&gt;

&lt;p&gt;Broken code fails loudly. You get a stack trace, a red test, a page that will not load. You fix it, and in fixing it you learn where you were wrong. That was the old deal: the bug was the tuition.&lt;/p&gt;

&lt;p&gt;The deal is still on offer, but the window is much shorter. Paste the error back, the fix comes down, the page loads, and you can be past it in forty seconds without ever having formed a theory of what went wrong. The failure still happens. Whether it teaches anything now depends entirely on whether you stopped to look, and there is a great deal of pressure not to stop.&lt;/p&gt;

&lt;p&gt;I have come to think that is the actual skill this era asks for. Not writing code from scratch. Choosing, over and over, to spend time on a failure you could have skipped.&lt;/p&gt;

&lt;p&gt;Because the other kind of failure gives you no such choice. Working code you do not understand fails quietly, and it fails later. Not on the day it is written. On the day the requirement changes, or the edge case finally shows up in production, or someone asks why it was done this way and the honest answer is that a model suggested it and the tests were green.&lt;/p&gt;

&lt;p&gt;The place this scares me most is multi-tenancy. In &lt;a href="https://andremarinho.me/projects/lawflow" rel="noopener noreferrer"&gt;the SaaS I run&lt;/a&gt;, each firm's data is separated by row-level security policies in Postgres. That code is short. It reads as though it were obvious. And if it is subtly wrong, nothing breaks: the app keeps working, the tests keep passing, and one firm sees another firm's clients. There is no stack trace for that. There is only whether I understood the policy I shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  I had seen this before, in marketing
&lt;/h2&gt;

&lt;p&gt;For eight years I ran a digital marketing agency, and the same failure had a different costume.&lt;/p&gt;

&lt;p&gt;Someone would find a competitor's funnel and copy it. Same offer structure, same ad angles, same email sequence, same landing page. Sometimes it worked for a while, which was the worst possible outcome, because it confirmed the method.&lt;/p&gt;

&lt;p&gt;Then something moved. The traffic source got more expensive, the audience shifted, the competitor dropped their price. And there was nothing to do, because there was no model of why any of it had been built that way in the first place. You cannot adjust a strategy you do not have. You can only go copy a newer one.&lt;/p&gt;

&lt;p&gt;A copied strategy is not a strategy. It is a screenshot of someone else's.&lt;/p&gt;

&lt;p&gt;AI-generated code is the same object. Often a very good one, drawn from more codebases than I will read in my life. But it is not a decision until I can say why it is this and not the other thing, and until then I am holding a screenshot. The screenshot works right up until something moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  What understanding buys you, concretely
&lt;/h2&gt;

&lt;p&gt;The reason to do the slow thing is not virtue. It is that the understanding comes back as leverage, and it comes back fast.&lt;/p&gt;

&lt;p&gt;Every problem I actually sat with turns into a better instruction the next time. The first version of a feature gets described as what I want. The second version gets described as what I want, minus the four things I now know go wrong. That second description is worth more than the code that came out of the first one, and it is not something a model can hand me. It is the residue of having watched something break and bothered to find out why.&lt;/p&gt;

&lt;p&gt;This is the compounding loop, and it runs on understanding specifically. Not on having typed the code myself. A model can only be as precise as the person steering it, and precision is made of things you learned the hard way.&lt;/p&gt;

&lt;p&gt;There is a second thing, harder to justify on a timesheet. When the mechanical part is handled, there is room to be curious about the domain instead of the syntax. I have read more about how Postgres actually evaluates a policy, or what a queue guarantees and does not, than I would have if I had been heads-down fighting the language. The headroom is real. It just has to be spent on something, and it will go to whatever you point it at, including nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually do about it
&lt;/h2&gt;

&lt;p&gt;None of this is a workflow I recommend to anyone else. It is what I do because I know exactly which mistake I am prone to.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ask for the reasoning before the code.&lt;/strong&gt; What were the alternatives, and why this one. I read that first. If the answer comes back generic, the code is usually generic too, and that is a signal about the prompt more than the model.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Explain it out loud before merging.&lt;/strong&gt; Not to anyone in particular. If I cannot say what a block does and what would break without it, I have not read it, I have skimmed it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Delete and rewrite the parts I will have to live with.&lt;/strong&gt; For the core of a feature, I close the suggestion and write it again from memory. It is slow, so I reserve it for code I know I will come back to. If I cannot reproduce it, I did not learn it. I watched it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Write down why.&lt;/strong&gt; &lt;a href="https://www.npmjs.com/package/@andre.marinho/create-mvp" rel="noopener noreferrer"&gt;create-mvp&lt;/a&gt; has 35 architecture decision records in it. The value was never the documentation. It was that "we chose X over Y because Z" is impossible to write when you do not know Z, so the gap has to come out into the open where I can see it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Break it on purpose.&lt;/strong&gt; Change a value, remove a guard, delete a line, and watch what fails. If nothing fails, either the code does nothing or the tests do not cover it, and both of those are worth finding out on a Tuesday instead of in production.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The honest uncertainty
&lt;/h2&gt;

&lt;p&gt;I do not know yet whether this holds up.&lt;/p&gt;

&lt;p&gt;The concern I cannot dismiss is that iterating faster means encountering more problems per hour and understanding each one less. Broad and shallow instead of narrow and deep. I have opinions about a lot of things I have only met once, through a failure that got fixed quickly, and I genuinely cannot tell from the inside which of those I know and which I merely recognize.&lt;/p&gt;

&lt;p&gt;What I can say is that the gap shows up eventually, and it shows up as a specific kind of silence: a question I cannot answer about code that is already running. I have collected a few of those. They are the most useful thing I have, because they are the only reliable signal of where the screenshot ends.&lt;/p&gt;

&lt;p&gt;AI is very good at the fifth CRUD form, at wiring I have already wired four times, at the boilerplate that teaches nothing on the sixth repetition. It is better than any tutorial I have found at explaining unfamiliar code, which turns out to be most of the job. And the speed is real: I shipped a SaaS to production with paying subscribers in my first year of full-time development, on a timeline that did not exist before.&lt;/p&gt;

&lt;p&gt;The goal was never to write code without help. It is to never be in a position where the only reason something works is that a model said so. Starting in this era means nobody makes me pay that price up front. I have to keep choosing to.&lt;/p&gt;

&lt;p&gt;I wrote about &lt;a href="https://andremarinho.me/posts/before-i-called-it-code" rel="noopener noreferrer"&gt;how I got here before any of this was my job&lt;/a&gt;, and about &lt;a href="https://andremarinho.me/posts/hello-world" rel="noopener noreferrer"&gt;why this site exists&lt;/a&gt;. If you think I have this wrong, I would like to hear it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>learning</category>
      <category>career</category>
      <category>programming</category>
    </item>
    <item>
      <title>When the event version was current but the snapshot wasn't</title>
      <dc:creator>André Marinho</dc:creator>
      <pubDate>Tue, 28 Jul 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/andrelmarinho/when-the-event-version-was-current-but-the-snapshot-wasnt-1cf1</link>
      <guid>https://dev.to/andrelmarinho/when-the-event-version-was-current-but-the-snapshot-wasnt-1cf1</guid>
      <description>&lt;p&gt;While investigating stored itinerary data in &lt;a href="https://andremarinho.me/projects/turistar" rel="noopener noreferrer"&gt;Turistar&lt;/a&gt; with SQL, I found that the snapshot content did not match the event history. The snapshot version could advance successfully while its itinerary stayed old or empty.&lt;/p&gt;

&lt;p&gt;Appending the events advanced their version. Updating the snapshot content required an optional argument that the caller wasn't sending. The database accepted the append and recorded the new version, leaving readers with a misleading checkpoint.&lt;/p&gt;

&lt;p&gt;The investigation led to a server-side snapshot builder, shipped in &lt;a href="https://github.com/andre-lmarinho/turistar/commit/7ef57059a9e79eaa7928b2e904016bdedb224ecf" rel="noopener noreferrer"&gt;the January 2026 change&lt;/a&gt;. It applies the events before persistence and sends the calculated state in the same database call as the event batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a snapshot version is supposed to mean
&lt;/h2&gt;

&lt;p&gt;Turistar transports itinerary changes as events such as &lt;code&gt;activity.created&lt;/code&gt;, &lt;code&gt;activity.moved&lt;/code&gt;, and &lt;code&gt;day.reordered&lt;/code&gt;. Each accepted event receives a monotonically increasing version within its plan. Other clients apply those events through the shared reducer.&lt;/p&gt;

&lt;p&gt;A snapshot lets a reader start from a materialized itinerary instead of rebuilding the plan from its first event. For that to work, the snapshot's version must describe the events already reflected in its content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;snapshot at version v + events v+1 through n = itinerary at version n

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the snapshot claims version &lt;code&gt;n&lt;/code&gt; but still contains the itinerary from an earlier version, a client requesting only events after &lt;code&gt;n&lt;/code&gt; cannot recover the missing changes. Comparing version numbers will not expose the missing content because the stored number itself is current.&lt;/p&gt;

&lt;h2&gt;
  
  
  The caller omitted the content argument
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/andre-lmarinho/turistar/blob/d4a77936cf63cca562ef53c1761f47402a8c59fc/src/features/app/planner/server/repositories/PlanEventsRepository.ts" rel="noopener noreferrer"&gt;repository before the fix&lt;/a&gt; called &lt;code&gt;append_plan_events&lt;/code&gt; with &lt;code&gt;plan_id&lt;/code&gt;, &lt;code&gt;base_version&lt;/code&gt;, and &lt;code&gt;events&lt;/code&gt;. It did not include &lt;code&gt;snapshot_state&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/andre-lmarinho/turistar/blob/0cb77827fe3979d0dcd7adc7dd928b5e4ad1e2d8/supabase/migrations/20250212134000_append_plan_events.sql" rel="noopener noreferrer"&gt;SQL function accepted that omission&lt;/a&gt;: &lt;code&gt;snapshot_state&lt;/code&gt; defaulted to &lt;code&gt;NULL&lt;/code&gt;. After inserting the events, the update used this expression:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;plan_snapshots&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;new_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snapshot_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan_snapshots&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;plan_snapshots&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;append_plan_events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no new state supplied, &lt;code&gt;COALESCE&lt;/code&gt; deliberately retained the previous state while the version advanced. When the function had to insert a missing snapshot, the equivalent fallback was &lt;code&gt;{"days":[]}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The event append and snapshot update could run together successfully and still produce an invalid checkpoint. Transactional execution could not compensate for the caller supplying events without their corresponding materialized state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the next state before appending
&lt;/h2&gt;

&lt;p&gt;The corrected &lt;a href="https://github.com/andre-lmarinho/turistar/blob/7ef57059a9e79eaa7928b2e904016bdedb224ecf/src/features/events/services/eventsQueries.ts" rel="noopener noreferrer"&gt;server append flow&lt;/a&gt; fetches the current snapshot, passes it and the incoming events to &lt;code&gt;buildSnapshotStateForAppend&lt;/code&gt;, then supplies the resulting &lt;code&gt;snapshot_state&lt;/code&gt; to the repository.&lt;/p&gt;

&lt;p&gt;For a snapshot whose version matches &lt;code&gt;baseVersion&lt;/code&gt;, the builder assigns the incoming events consecutive versions beginning at &lt;code&gt;baseVersion + 1&lt;/code&gt;. It applies them with the same reducer used to interpret the event history and serializes the resulting days as JSON-safe state.&lt;/p&gt;

&lt;p&gt;The database still checks &lt;code&gt;base_version&lt;/code&gt; under its snapshot row lock. If another append wins between the server's read and its write, the database sees the version mismatch and returns the current version without inserting that batch. Calculating a snapshot on the server does not replace the database's concurrency check.&lt;/p&gt;

&lt;p&gt;When the versions match and the server supplies the calculated state, the RPC writes events and their resulting snapshot together. The client does not need to follow a successful append with a separate snapshot update that could fail independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recover a known empty-snapshot failure
&lt;/h2&gt;

&lt;p&gt;An empty snapshot with a nonzero version is a specific sign that this recovery path can investigate. The server fetches the plan's history from version zero, and the &lt;a href="https://github.com/andre-lmarinho/turistar/blob/7ef57059a9e79eaa7928b2e904016bdedb224ecf/src/features/snapshots/services/snapshotStateBuilder.ts" rel="noopener noreferrer"&gt;snapshot builder&lt;/a&gt; checks that the history's highest version equals the requested base version before rebuilding.&lt;/p&gt;

&lt;p&gt;It replays that history onto empty days at version zero, then applies the incoming batch. This lets a later append reconstruct an empty materialization from the retained log, rather than treating the empty days as the correct starting point.&lt;/p&gt;

&lt;p&gt;There are limits to that recovery. The builder does not inspect every nonempty snapshot for incorrect content, and checking the highest history version is not a proof that every expected event is present. Returning &lt;code&gt;null&lt;/code&gt; from the builder also does not itself reject an append: the caller can still invoke the RPC without a replacement state. The fix addresses the normal state-building path and the identified empty-snapshot case; it is not a general audit of every stored checkpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test state as well as versions
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/andre-lmarinho/turistar/blob/7ef57059a9e79eaa7928b2e904016bdedb224ecf/src/features/snapshots/services/snapshotStateBuilder.test.ts" rel="noopener noreferrer"&gt;builder tests added with the change&lt;/a&gt; check version mismatch, empty input, missing recovery history, and the resulting activity content for a valid append. An assertion that the version increased would have missed the original problem.&lt;/p&gt;

&lt;p&gt;I have not retained the exact SQL queries used during the investigation or a count of affected people. The repository history preserves the omitted argument, the SQL fallback, and the state-building change.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>eventsourcing</category>
      <category>supabase</category>
      <category>dataintegrity</category>
    </item>
    <item>
      <title>Why my Postgres pagination cursor needs a timestamp and an ID</title>
      <dc:creator>André Marinho</dc:creator>
      <pubDate>Thu, 23 Jul 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/andrelmarinho/why-my-postgres-pagination-cursor-needs-a-timestamp-and-an-id-2j9l</link>
      <guid>https://dev.to/andrelmarinho/why-my-postgres-pagination-cursor-needs-a-timestamp-and-an-id-2j9l</guid>
      <description>&lt;p&gt;The read-notification history in &lt;a href="https://andremarinho.me/projects/lawflow" rel="noopener noreferrer"&gt;LawFlow&lt;/a&gt; used offset pagination. The interface only needed to load older notifications, but the query described its position as a number of rows to skip. Each deeper request asked PostgreSQL to compute and discard a larger prefix of the result.&lt;/p&gt;

&lt;p&gt;I replaced the offset with a cursor containing &lt;code&gt;sent_at&lt;/code&gt; and &lt;code&gt;id&lt;/code&gt;. The timestamp located the boundary in the history; the ID made that boundary unambiguous when several notifications shared a timestamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  A timestamp does not completely order the inbox
&lt;/h2&gt;

&lt;p&gt;Notifications can share &lt;code&gt;sent_at&lt;/code&gt;, including several generated in the same batch. Ordering only by that field leaves their relative order unspecified.&lt;/p&gt;

&lt;p&gt;Suppose a page ends partway through a group with the same timestamp. A next-page predicate of &lt;code&gt;sent_at &amp;lt; lastTimestamp&lt;/code&gt; skips the rest of that group. Changing it to &lt;code&gt;&amp;lt;=&lt;/code&gt; can return rows already displayed. Increasing the timestamp's precision would still leave correctness dependent on timestamps being unique.&lt;/p&gt;

&lt;p&gt;The query therefore orders by both fields:&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;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The primary-key ID supplies a unique tie-breaker. It does not need to encode creation time because &lt;code&gt;sent_at&lt;/code&gt; provides the chronological order. The same two fields must appear in the cursor and in the continuation condition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Continue strictly after the last returned row
&lt;/h2&gt;

&lt;p&gt;A shortened SQL equivalent of the paginated query is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;notifications&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;read&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&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;sent_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&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;sent_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here &lt;code&gt;$3&lt;/code&gt; and &lt;code&gt;$4&lt;/code&gt; are the last displayed row's timestamp and ID; &lt;code&gt;$5&lt;/code&gt; is the requested page size plus one. The first page omits the cursor predicate. Workspace, recipient and read-status filters remain on every request. A cursor identifies a position within that authorized query; it grants no access by itself.&lt;/p&gt;

&lt;p&gt;Because the order is descending, older timestamps come after the boundary. At the same timestamp, smaller IDs come after it. Using &lt;code&gt;&amp;lt;&lt;/code&gt; on both branches keeps the boundary exclusive. Changing the sort direction would require changing these comparisons too.&lt;/p&gt;

&lt;p&gt;The Supabase repository expresses the same condition through &lt;code&gt;.or()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`sent_at.lt.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sentAt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,and(sent_at.eq.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sentAt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,id.lt.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cursor&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="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;The API validates the cursor as a timestamp and UUID before it reaches that filter. The client takes the next cursor from the last row actually displayed, preserving the timestamp value returned by the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  One extra row answers the UI's question
&lt;/h2&gt;

&lt;p&gt;For a requested page size of 20, the repository asks for at most 21 rows. It returns the first 20 and sets &lt;code&gt;hasMore&lt;/code&gt; when the result contained the extra row. The client continues from row 20. Using row 21 as the cursor would skip a notification that had never been displayed.&lt;/p&gt;

&lt;p&gt;An empty result ends the traversal. A result with exactly 20 rows and no extra row also has &lt;code&gt;hasMore: false&lt;/code&gt;. This answers whether another page existed when the query ran, without requesting an exact total just to draw a Load more control.&lt;/p&gt;

&lt;p&gt;The interface gives up direct jumps to an arbitrary page number. That fits an inbox where the action is to read further back. A numbered reporting table could require a different tradeoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  The indexes still determine how much work remains
&lt;/h2&gt;

&lt;p&gt;PostgreSQL's &lt;a href="https://www.postgresql.org/docs/current/queries-limit.html" rel="noopener noreferrer"&gt;LIMIT and OFFSET documentation&lt;/a&gt; explains both requirements behind the change: pagination needs a predictable order, and skipped offset rows still require work on the server. Adding an index to an offset query does not remove that skipped prefix.&lt;/p&gt;

&lt;p&gt;The cursor migration added a B-tree index on &lt;code&gt;(sent_at DESC, id DESC)&lt;/code&gt;. The schema already had another index on &lt;code&gt;(workspace_id, user_id, sent_at DESC)&lt;/code&gt;. Those are different access paths. The first matches the complete ordering but does not begin with the tenant and recipient filters. The second begins with those filters but does not contain the ID tie-breaker or the read-status filter.&lt;/p&gt;

&lt;p&gt;Neither index covers all the filters and the complete ordering. PostgreSQL still chooses a plan and may filter rows or sort. The &lt;a href="https://www.postgresql.org/docs/current/indexes-ordering.html" rel="noopener noreferrer"&gt;index-ordering rules&lt;/a&gt; explain when a B-tree can supply ordered results directly. Tenant distribution, read-status selectivity and timestamp ties still matter.&lt;/p&gt;

&lt;p&gt;I compared the offset and cursor versions with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;, checking the first and fiftieth pages against a seeded history of roughly 50,000 notifications. This was preventive work: the product's actual volume did not show a noticeable user-facing difference. The comparison supported removing offset-driven degradation in that scenario; it did not establish constant latency for every dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stable boundaries do not freeze the history
&lt;/h2&gt;

&lt;p&gt;Newer notifications can arrive above the current boundary without shifting the cursor's position as they would shift an offset. That does not give the traversal a database snapshot across requests. Deletions, notifications moving between read and unread, or changes to ordering fields can still change what later pages contain.&lt;/p&gt;

&lt;p&gt;The client refreshes read history from the first page when it needs a fresh view and merges loaded notifications by ID. The composite cursor solves the ordering and continuation problem for that flow. It does not promise a permanent, unchanged enumeration of a live inbox.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>pagination</category>
      <category>performance</category>
    </item>
    <item>
      <title>I Was Building Software Before I Called It Code</title>
      <dc:creator>André Marinho</dc:creator>
      <pubDate>Tue, 17 Feb 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/andrelmarinho/i-was-building-software-before-i-called-it-code-2129</link>
      <guid>https://dev.to/andrelmarinho/i-was-building-software-before-i-called-it-code-2129</guid>
      <description>&lt;p&gt;In 2014 I worked in the finance department at Grupo Hemocat, a medical device distributor in Salvador. The finance manager updated a planning spreadsheet by hand, copying information from a printed system report almost every day.&lt;/p&gt;

&lt;p&gt;I asked her if I could try to automate it. That became my first technical project: learning how the system's database worked and connecting Excel directly to the local SQL database.&lt;/p&gt;

&lt;p&gt;In July 2025, software development became my main occupation. A lot of the work that led me there happened in finance, logistics and business consulting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting the spreadsheet to its source
&lt;/h2&gt;

&lt;p&gt;The spreadsheet already existed. The task was to let it read from the system instead of depending on someone to copy the figures across.&lt;/p&gt;

&lt;p&gt;That changed what a discrepancy meant. If a value was wrong in the spreadsheet, we could trace it back to the source instead of wondering whether it had been mistyped during the update.&lt;/p&gt;

&lt;p&gt;It did not remove the need to check the numbers. Errors in the system still needed someone to investigate them. What it removed was the repeated work of extracting and transferring the information.&lt;/p&gt;

&lt;p&gt;I was learning about databases by working with one that people already depended on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then came logistics
&lt;/h2&gt;

&lt;p&gt;In 2015 I became logistics supervisor at the same company, working with inventory, distribution, purchasing and imports.&lt;/p&gt;

&lt;p&gt;I modelled stock availability and built a Power BI dashboard to support purchasing and distribution decisions. A unit in stock is not one thing. It can be available, committed, in transit, in customs or on consignment. Treating all of those as the same number is how you promise a hospital something you cannot deliver.&lt;/p&gt;

&lt;p&gt;The model had to reflect those differences so that purchasing, sales and logistics could work from the same view. Getting the data onto a screen was only part of the job; deciding what it represented mattered just as much.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Duonorth changed
&lt;/h2&gt;

&lt;p&gt;In 2017 I started Duonorth Studio as a business intelligence and business solutions consultancy. I worked with clients to understand their operations, reconcile data and build dashboards, spreadsheets and automations they could use in their daily work.&lt;/p&gt;

&lt;p&gt;Around 2020, Duonorth expanded into marketing and digital delivery. The work connected business analysis with campaigns, websites, landing pages and software. I handled diagnosis, scope and the relationship with the client, bringing in specialists as the work grew.&lt;/p&gt;

&lt;p&gt;From 2023, I concentrated the business on websites and software. I was both directing the work and implementing it: WordPress sites, themes, plugins, interfaces and integrations in HTML, CSS, JavaScript and PHP.&lt;/p&gt;

&lt;p&gt;In July 2025, software product development became my main occupation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What carried over
&lt;/h2&gt;

&lt;p&gt;I still ask where the data lives before designing anything on top of it, and what someone needs when they bring me a requirement. Knowing what they will do with the answer helps me design the interface. I also draw on the experience of agreeing on a scope with a client and following it through delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm still learning
&lt;/h2&gt;

&lt;p&gt;The current work takes me deeper into relational databases, authentication, security, automated tests and continuous delivery. Earlier experience with dashboards and websites helps, but it does not answer every question that comes up in a software product.&lt;/p&gt;

&lt;p&gt;That experience helps me frame the problem. I still have to test my assumptions about the systems I build and learn from what breaks.&lt;/p&gt;

&lt;p&gt;I wrote more about the learning part in &lt;a href="https://andremarinho.me/posts/learning-to-code-with-ai" rel="noopener noreferrer"&gt;working with AI without skipping the understanding&lt;/a&gt;. The current work is on &lt;a href="https://github.com/andre-lmarinho" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>career</category>
      <category>story</category>
      <category>businessintelligence</category>
    </item>
  </channel>
</rss>
