<?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: Tommy</title>
    <description>The latest articles on DEV Community by Tommy (@banh).</description>
    <link>https://dev.to/banh</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%2F4006888%2F21568185-e0ec-46eb-b848-6b71b7411110.png</url>
      <title>DEV Community: Tommy</title>
      <link>https://dev.to/banh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/banh"/>
    <language>en</language>
    <item>
      <title>World Cup Data Pipeline</title>
      <dc:creator>Tommy</dc:creator>
      <pubDate>Fri, 17 Jul 2026 06:44:17 +0000</pubDate>
      <link>https://dev.to/banh/world-cup-data-pipeline-1721</link>
      <guid>https://dev.to/banh/world-cup-data-pipeline-1721</guid>
      <description>&lt;h1&gt;
  
  
  How I Built a CSV-to-Postgres Data Pipeline for World Cup Player Stats
&lt;/h1&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;With excited of watching the World Cup for the past month, I wanted to my own project that would show the stats of all players that have played in the tournament.&lt;/p&gt;

&lt;p&gt;One of the major issues that I ran into was that the CSV file I had wasn't really one dataset. It flattened together two different kinds of records. Outfield players have fields like goals, assists, and yellow or red cards. Goalkeepers have saves, clean sheets, and goals conceded instead. Representing both in a single table would mean filling half the columns with meaningless &lt;code&gt;NULL&lt;/code&gt; values depending on the player's position.&lt;/p&gt;

&lt;p&gt;I wanted something closer to a real data pipeline than a spreadsheet import. The goal was to read the raw CSV, validate it, transform each row into the shape it actually belonged to, store it in a normalized Postgres schema, and expose it through an API instead of treating the CSV as the application's source of truth.&lt;/p&gt;

&lt;p&gt;The project deliberately focused on the unglamorous middle of backend engineering: ingesting data, transforming it correctly, storing it efficiently, and serving it reliably.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schema Design Decisions
&lt;/h3&gt;

&lt;p&gt;The first design decision happened during the transform step. Instead of forcing every record into the same schema, the pipeline checks &lt;code&gt;position === "GK"&lt;/code&gt; and routes each row into either a player shape or a goalkeeper shape. Each shape lands in its own normalized Postgres table, which avoids maintaining a table full of columns that only apply to half the records.&lt;/p&gt;

&lt;p&gt;The next challenge came from Postgres itself. It has a hard limit of 65,535 bound parameters per query, which means a single giant &lt;code&gt;INSERT&lt;/code&gt; eventually stops working as the dataset grows. Rather than inserting everything at once, the pipeline chunks writes into batches: 200 players or 30 goalkeepers per &lt;code&gt;INSERT&lt;/code&gt;. Each batch generates its own &lt;code&gt;$1..$N&lt;/code&gt; placeholder sequence, keeping every query comfortably below the parameter limit regardless of how large the CSV becomes.&lt;/p&gt;

&lt;p&gt;I also wanted the pipeline to tolerate partial failures instead of treating every error as fatal. Player and goalkeeper batches execute through &lt;code&gt;Promise.allSettled&lt;/code&gt;, so a failed goalkeeper batch doesn't prevent successful player batches from committing. Within each batch, the pipeline continues attempting later chunks even if an earlier one fails, then reports exactly how many rows were inserted compared to how many were attempted.&lt;/p&gt;

&lt;p&gt;That design became even more important once I considered running the pipeline multiple times against refreshed data. After verifying that &lt;code&gt;(name, country)&lt;/code&gt; produced zero collisions across all 1,247 rows, I added a unique constraint through a Knex migration and changed inserts to &lt;code&gt;ON CONFLICT (name, country) DO UPDATE&lt;/code&gt;. Re-running the pipeline now updates existing rows instead of creating duplicates.&lt;/p&gt;

&lt;h3&gt;
  
  
  API Architecture
&lt;/h3&gt;

&lt;p&gt;The serving layer is intentionally small. Everything flows through a single Express 5 endpoint: &lt;code&gt;GET /players&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Requests first pass through rate limiting, which allows 100 requests every 15 minutes. After that, the endpoint uses a cache-aside strategy backed by Redis with a one-hour TTL. The first request reads from Postgres and populates Redis. Every subsequent request during that hour reads directly from the cache instead of hitting the database again.&lt;/p&gt;

&lt;p&gt;The part that required the most thought wasn't caching itself—it was making sure the cache couldn't become a single point of failure.&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Redis cache operations&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Fall back to Postgres&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redis operations are isolated inside their own &lt;code&gt;try/catch&lt;/code&gt;, separate from the Postgres query. If Redis becomes unavailable, the endpoint doesn't return a 500 error. Instead, it skips the cache, serves data directly from Postgres, logs the Redis failure, and continues responding normally. Losing the cache should never mean losing the API.&lt;/p&gt;

&lt;p&gt;That same philosophy of graceful degradation shows up elsewhere in the project. The ingestion pipeline is designed to continue processing remaining chunks after individual failures, and the API is designed to continue serving data when an infrastructure component disappears. Both decisions favor availability over an all-or-nothing approach.&lt;/p&gt;

&lt;p&gt;Keeping the serving layer intentionally small also helped keep responsibilities clear. The pipeline owns reading, validating, transforming, and storing the data. The API owns exposing that data efficiently, with Redis acting as a performance optimization instead of becoming a dependency the application cannot function without.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Debugging Rabbit Hole
&lt;/h3&gt;

&lt;p&gt;The bug that taught me the most wasn't dramatic. Nothing crashed. Nothing printed an error.&lt;/p&gt;

&lt;p&gt;After refactoring row-by-row inserts into batch inserts, I built the values array, generated the placeholder groups using the correct offset math (&lt;code&gt;base = index * columns.length&lt;/code&gt;), and saw success messages in the console. The pipeline reported that player data had been stored successfully, and there were no visible failures.&lt;/p&gt;

&lt;p&gt;Except the database hadn't changed.&lt;/p&gt;

&lt;p&gt;I compared row counts before and after running the entire pipeline, and they were identical. The SQL was built correctly. The values array was correct. The placeholder math was correct.&lt;/p&gt;

&lt;p&gt;The problem was almost embarrassingly simple: after constructing the query, I never actually called &lt;code&gt;pool.query()&lt;/code&gt;. The function finished immediately after building the &lt;code&gt;.map()&lt;/code&gt;. Nothing threw an exception because nothing that could have thrown was ever executed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson that stuck is that logs only prove your function returned—they don't prove your data changed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're writing a data pipeline, verify the state of the database itself. Check row counts. Run a &lt;code&gt;SELECT&lt;/code&gt;. Trust what the database confirms, not what your console prints.&lt;/p&gt;

&lt;h3&gt;
  
  
  Outcome
&lt;/h3&gt;

&lt;p&gt;The finished project is deployed on Render, backed by Supabase Postgres and a Redis cache. It includes a test suite of 19 Jest and Supertest tests running against an isolated Postgres &lt;code&gt;test&lt;/code&gt; schema so production data is never touched. The suite covers real constraint failures, per-chunk failure behavior, and the &lt;code&gt;ON CONFLICT&lt;/code&gt; upsert path.&lt;/p&gt;

&lt;p&gt;The pipeline is now operational. Running it against a refreshed CSV updates existing records instead of inserting duplicates, making repeated imports a normal workflow rather than something to avoid.&lt;/p&gt;

&lt;p&gt;You can try the live project here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live: &lt;a href="https://world-cup-data-pipeline.onrender.com" rel="noopener noreferrer"&gt;https://world-cup-data-pipeline.onrender.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/Banhhmii/World-Cup-Data-Pipeline" rel="noopener noreferrer"&gt;https://github.com/Banhhmii/World-Cup-Data-Pipeline&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This became Portfolio Project #2 on my self-taught software engineering roadmap. The goal wasn't to build another CRUD application—it was to build something with a completely different shape: ingest, transform, chunked storage, caching, validation, and graceful degradation working together as a data pipeline. That difference is exactly what satisfies the Week 16 milestone of shipping two backend projects that tell different technical stories.&lt;/p&gt;

&lt;p&gt;More than anything else, this project reinforced one idea I'll carry into every future backend system: when software writes data, the database—not the logs—is the ground truth.&lt;/p&gt;

</description>
      <category>database</category>
      <category>dataengineering</category>
      <category>postgres</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Twitter Bookmark Organizer</title>
      <dc:creator>Tommy</dc:creator>
      <pubDate>Sun, 28 Jun 2026 19:01:14 +0000</pubDate>
      <link>https://dev.to/banh/how-i-built-a-secure-rest-api-to-organize-my-twitter-bookmarks-j96</link>
      <guid>https://dev.to/banh/how-i-built-a-secure-rest-api-to-organize-my-twitter-bookmarks-j96</guid>
      <description>&lt;h1&gt;
  
  
  How I Built a Secure REST API to Organize My Twitter Bookmarks
&lt;/h1&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;My Twitter bookmark list was a graveyard. Hundreds of saved tweets — digital art, career advice, ai news — with no way to find anything. Twitter's native bookmarks have zero organization. I needed a way to save a URL, tag it, and pull it back up later. Currently, Twitter does have a folder system, but it is locked behind a pay wall.&lt;/p&gt;

&lt;p&gt;So I built one. A backend REST API with exactly three features: store a bookmark, tag it, and filter by tag. No Chrome extension, no AI auto-tagging, no slick UI. Just a backend I could actually ship.&lt;/p&gt;




&lt;h2&gt;
  
  
  Schema Design Decisions
&lt;/h2&gt;

&lt;p&gt;The database has two tables: &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;bookmarks&lt;/code&gt;. The decisions behind them were deliberate.&lt;/p&gt;

&lt;p&gt;On the &lt;code&gt;users&lt;/code&gt; table, &lt;code&gt;username&lt;/code&gt; is &lt;code&gt;UNIQUE NOT NULL&lt;/code&gt; enforced at the database level — not just in the app. App-level checks can have race conditions (two requests land at the same millisecond; both pass the check; both try to insert). A &lt;code&gt;UNIQUE&lt;/code&gt; constraint at the DB level makes that a hard stop, not a maybe.&lt;/p&gt;

&lt;p&gt;On the &lt;code&gt;bookmarks&lt;/code&gt; table, &lt;code&gt;user_id&lt;/code&gt; is a foreign key with &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;. Every bookmark is owned by exactly one user. If that user is deleted, their bookmarks go with them — automatically, without needing a separate cleanup query. No orphaned rows left rotting in the database.&lt;/p&gt;

&lt;p&gt;I also separated my two database tools intentionally. Knex handles migrations — versioned, rollbackable schema changes. &lt;code&gt;pg.Pool&lt;/code&gt; handles all runtime queries. They look interchangeable at first glance, but they're not. Knex is for evolving the schema; the pool is for talking to it day-to-day. Mixing them causes subtle bugs that are hard to trace.&lt;/p&gt;




&lt;h2&gt;
  
  
  API Architecture
&lt;/h2&gt;

&lt;p&gt;Five routes: &lt;code&gt;POST /register&lt;/code&gt;, &lt;code&gt;POST /login&lt;/code&gt;, &lt;code&gt;POST /storeBookmark&lt;/code&gt;, &lt;code&gt;GET /filterBookmarks&lt;/code&gt;, and &lt;code&gt;GET /bookmarks&lt;/code&gt;. The auth and bookmark endpoints are kept separate on purpose — different middleware applies to each.&lt;/p&gt;

&lt;p&gt;Protected routes follow a consistent middleware chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rateLimiter → authenticateUser → validateBookmark → handler
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order is intentional. Rate limiting runs first because it's the cheapest thing to reject — no DB call, no token verification, just check the IP and bail. Auth runs next. Validation runs last, right before the handler that does real work.&lt;/p&gt;

&lt;p&gt;Every catch block in every route calls &lt;code&gt;next(error)&lt;/code&gt; — never &lt;code&gt;res.json()&lt;/code&gt;. This ensures all errors, no matter where they originate, flow through a single centralized error handler. That handler uses a custom &lt;code&gt;AppError&lt;/code&gt; class hierarchy (&lt;code&gt;AuthError&lt;/code&gt;, &lt;code&gt;ValidationError&lt;/code&gt;, &lt;code&gt;ConflictError&lt;/code&gt;) to map errors to the right HTTP status. And critically: any unrecognized 500-level error gets its internal message stripped. Clients only ever see &lt;code&gt;"Internal Server Error"&lt;/code&gt; — never a stack trace, never a SQL query, never a file path.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Debugging Rabbit Hole
&lt;/h2&gt;

&lt;p&gt;I spent hours staring at &lt;code&gt;ECONNREFUSED&lt;/code&gt; errors on my database connection. I checked my Supabase credentials. I regenerated the connection string. I restarted the server repeatedly. The credentials looked right in my &lt;code&gt;.env&lt;/code&gt; file. I would go back and forth from one project to the current one just see if I typed something wrong. Nothing worked.&lt;/p&gt;

&lt;p&gt;The fix was one line I had forgotten: &lt;code&gt;dotenv.config()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dotenv&lt;/code&gt; reads your &lt;code&gt;.env&lt;/code&gt; file and loads the values into &lt;code&gt;process.env&lt;/code&gt;. But if you never call it, &lt;code&gt;process.env.PG_CONNECTION_STRING&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt; when the app starts — and PostgreSQL tries to connect to nothing. The error looks exactly like a database problem. It isn't. It's an environment problem masquerading as one.&lt;/p&gt;

&lt;p&gt;The lesson I took: when you see &lt;code&gt;ECONNREFUSED&lt;/code&gt;, log &lt;code&gt;process.env.PG_CONNECTION_STRING&lt;/code&gt; before you touch anything else. If it's &lt;code&gt;undefined&lt;/code&gt;, stop — the bug isn't in your database config, it's upstream. Also, &lt;code&gt;dotenv.config()&lt;/code&gt; must be the very first call in your entry file. Any module loaded before it that reads &lt;code&gt;process.env&lt;/code&gt; will get &lt;code&gt;undefined&lt;/code&gt;, even if you add the call later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Outcome
&lt;/h2&gt;

&lt;p&gt;The app is a working REST API where authenticated users can store, tag, and retrieve bookmarks. Under the hood it ships six independent security layers: parameterized SQL queries (injection prevention), async bcrypt hashing (password security), stateless JWT auth with 15-minute expiry, two-tier rate limiting (stricter on auth routes to blunt brute-force attempts), input validation with &lt;code&gt;express-validator&lt;/code&gt;, and BOLA prevention — every bookmark query is scoped to &lt;code&gt;req.user.userId&lt;/code&gt; so no user can read another's data.&lt;/p&gt;

&lt;p&gt;It also has a test suite: integration tests with Jest and Supertest against a real database, plus a mock-based suite that simulates database crashes to verify the error handler works without needing a real failure.&lt;/p&gt;

&lt;p&gt;This project covered Weeks 9–12 of my SWE roadmap: building servers, designing database schemas, layering in security, and proving correctness with tests. More importantly, it taught me that the bugs that cost the most time are rarely in the code you're focused on — they're in the invisible infrastructure surrounding it.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>showdev</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
