<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged node</title>
<link>https://coderlegion.com/tag/node</link>
<description>Powered by Question2Answer</description>
<item>
<title>Node 22.22 runs TypeScript. Ubuntu's Node 22.22 doesn't.</title>
<link>https://coderlegion.com/32697/node-22-22-runs-typescript-ubuntus-node-22-22-doesnt</link>
<description>&lt;p&gt;Four test files in my site's repo import TypeScript directly. &lt;code&gt;faq.test.mjs&lt;/code&gt; pulls in &lt;code&gt;src/lib/faq.ts&lt;/code&gt;, and three others do the same with their own modules. There is no build step and no loader in between: the script is &lt;code&gt;node --test &quot;test/*.test.mjs&quot;&lt;/code&gt;, and type stripping has been on by default since Node 22.18.0, so Node strips the types and runs what's left.&lt;/p&gt;
&lt;p&gt;In CI those tests pass. The workflow asks &lt;code&gt;actions/setup-node&lt;/code&gt; for &lt;code&gt;node-version: &quot;22&quot;&lt;/code&gt;, and the latest runs on &lt;code&gt;main&lt;/code&gt; are green.&lt;/p&gt;
&lt;p&gt;On my Ubuntu server, with Node 22.22.1, all four fail before a single assertion runs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension &quot;.ts&quot; for .../src/lib/faq.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;22.22.1 is newer than 22.18.0, so on paper the feature is there. &lt;strong&gt;The version number was right. The binary behind it was a different build.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;same-version-different-binary&quot;&gt;Same version, different binary&lt;/h3&gt;
&lt;p&gt;Node will tell you whether it strips types. The v22 docs describe &lt;code&gt;process.features.typescript&lt;/code&gt; as &lt;code&gt;&quot;strip&quot;&lt;/code&gt; by default, &lt;code&gt;&quot;transform&quot;&lt;/code&gt; with &lt;code&gt;--experimental-transform-types&lt;/code&gt;, and &lt;code&gt;false&lt;/code&gt; if Node is started with &lt;code&gt;--no-experimental-strip-types&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I did not pass that flag:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-console&quot;&gt;$ /usr/bin/node --version
v22.22.1
$ /usr/bin/node -p 'process.features.typescript'
false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;node&lt;/code&gt; comes from Ubuntu's &lt;code&gt;nodejs&lt;/code&gt; package, version &lt;code&gt;22.22.1+dfsg+~cs22.19.15-1ubuntu1&lt;/code&gt;. Asking for the feature explicitly does not bring it back:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-console&quot;&gt;$ /usr/bin/node --experimental-strip-types t.ts
node:internal/util:226
    throw new ERR_NO_TYPESCRIPT();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To rule out the version itself, I downloaded the official 22.22.1 build from nodejs.org, checked it against &lt;code&gt;SHASUMS256.txt&lt;/code&gt;, and asked the same question. It printed &lt;code&gt;strip&lt;/code&gt; and ran the same &lt;code&gt;.ts&lt;/code&gt; file without complaint.&lt;/p&gt;
&lt;h3 id=&quot;where-typescript-went&quot;&gt;Where TypeScript went&lt;/h3&gt;
&lt;p&gt;The package's own changelog explains most of it. The December 2024 entry, &lt;code&gt;22.12.0+dfsg-1&lt;/code&gt;, says:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;dfsg-exclude amaro, build without it - it requires swc. This disables the ability to execute TypeScript files using the --experimental-strip-types flag.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The November 2025 entry, &lt;code&gt;22.21.1+dfsg+~cs22.19.0-1&lt;/code&gt;, says: &quot;we build without-amaro for now, disable strip-types&quot;.&lt;/p&gt;
&lt;p&gt;The March 2026 Debian entry, merged into the version I have, says: &quot;Drop 'no amaro' patch, solved upstream&quot;. The build still answers &lt;code&gt;false&lt;/code&gt;, and it reports how it was compiled:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-console&quot;&gt;$ /usr/bin/node -p 'process.config.variables.node_use_amaro'
false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The official 22.22.1 build prints &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The changelog doesn't say what &quot;solved upstream&quot; changed. My tests only see what the build does.&lt;/p&gt;
&lt;h3 id=&quot;why-ci-never-saw-it&quot;&gt;Why CI never saw it&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;setup-node&lt;/code&gt; README says the action first checks the runner's tool cache, then pulls LTS versions from the &lt;code&gt;actions/node-versions&lt;/code&gt; releases and, on a miss, falls back to downloading from nodejs.org. None of those is Ubuntu's package. The four test files pass in CI, so the Node there strips types. The one on my server does not, and both report major version 22.&lt;/p&gt;
&lt;p&gt;That is how one repository can be green in CI and red on my server without a line of code changing.&lt;/p&gt;
&lt;h3 id=&quot;the-fix&quot;&gt;The fix&lt;/h3&gt;
&lt;p&gt;I extracted the official Node 22.23.2 tarball into &lt;code&gt;/usr/local&lt;/code&gt;. &lt;code&gt;/usr/local/bin&lt;/code&gt; comes before &lt;code&gt;/usr/bin&lt;/code&gt; in &lt;code&gt;PATH&lt;/code&gt;, so &lt;code&gt;node&lt;/code&gt; now resolves to the official build, with npm 10.9.8 bundled.&lt;/p&gt;
&lt;p&gt;I left the Ubuntu package installed. On this machine 15 installed packages depend on &lt;code&gt;nodejs&lt;/code&gt;: &lt;code&gt;handlebars&lt;/code&gt; and 14 &lt;code&gt;node-*&lt;/code&gt; libraries. Removing it to get the right &lt;code&gt;node&lt;/code&gt; would have meant fighting apt over things that have nothing to do with my site.&lt;/p&gt;
&lt;p&gt;Same four files, official binary:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tap&quot;&gt;# tests 42
# pass 42
# fail 0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;ask-node-what-it-can-do&quot;&gt;Ask Node what it can do&lt;/h3&gt;
&lt;p&gt;If a project relies on type stripping, &lt;code&gt;node --version&lt;/code&gt; is not the check. This is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;node -p 'process.features.typescript'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the two builds above it prints &lt;code&gt;strip&lt;/code&gt; when the feature works and &lt;code&gt;false&lt;/code&gt; when it doesn't, whatever the version string says. It belongs in a setup script next to the version check, because on my server the version check was the one that passed.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32697/node-22-22-runs-typescript-ubuntus-node-22-22-doesnt</guid>
<pubDate>Tue, 27 Oct 2026 14:27:58 +0000</pubDate>
</item>
<item>
<title>Building a Booking System with Node.js, Google Calendar, and Google Meet</title>
<link>https://coderlegion.com/32649/building-a-booking-system-with-node-js-google-calendar-and-google-meet</link>
<description>&lt;h3 id=&quot;introduction&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;Booking a consultation should be straightforward for the person making the appointment. Behind the scenes, however, an automated booking system has to coordinate several moving parts: collecting customer information, checking availability, scheduling an event, generating a meeting link, saving the booking, and notifying the people involved.&lt;/p&gt;
&lt;p&gt;I encountered these challenges while developing the booking system for JOPAD Advisory. I built the system from scratch, using Node.js and Express for the backend, MongoDB for storing bookings, Google Calendar for scheduling, Google Meet for virtual meetings, and Resend for email notifications.&lt;/p&gt;
&lt;p&gt;In this article, I'll walk through how I implemented the booking workflow, how the different services work together, and some of the engineering considerations that come with connecting external APIs to a backend application.&lt;/p&gt;
&lt;h3 id=&quot;1-the-architecture&quot;&gt;1. The Architecture&lt;/h3&gt;
&lt;p&gt;The backend coordinates the booking process. Each service has a specific responsibility:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Node.js and Express: Handle booking requests and coordinate the workflow.&lt;/li&gt;
&lt;li&gt;MongoDB and Mongoose: Store booking details and provide access to existing appointments.&lt;/li&gt;
&lt;li&gt;Google Calendar API: Create calendar events for scheduled sessions.&lt;/li&gt;
&lt;li&gt;Google Meet: Generate a virtual meeting link through the Calendar event's conference configuration.&lt;/li&gt;
&lt;li&gt;Airtable: Receive a secondary record of the booking.&lt;/li&gt;
&lt;li&gt;Resend: Power the email notification workflow.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The booking process follows this sequence:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A customer submits a booking request through the website.&lt;/li&gt;
&lt;li&gt;The Express endpoint validates the submitted fields.&lt;/li&gt;
&lt;li&gt;The backend calculates the appointment's start and end times.&lt;/li&gt;
&lt;li&gt;Existing bookings are checked for overlapping time intervals.&lt;/li&gt;
&lt;li&gt;Google Calendar creates an event and requests a Google Meet conference.&lt;/li&gt;
&lt;li&gt;The booking is saved to MongoDB with its meeting link.&lt;/li&gt;
&lt;li&gt;The backend attempts to send a secondary record to Airtable.&lt;/li&gt;
&lt;li&gt;Booking notification emails are triggered.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This design brings several services together behind a single booking endpoint, giving the frontend one place to submit the request.&lt;/p&gt;
&lt;h3 id=&quot;2-building-the-booking-endpoint&quot;&gt;2. Building the Booking Endpoint&lt;/h3&gt;
&lt;p&gt;I used an Express POST endpoint to receive booking requests. The request contains the appointment time, duration, customer's contact details, session description, and other booking information.&lt;/p&gt;
&lt;p&gt;The first step is to reject incomplete requests before performing external API operations.&lt;/p&gt;
&lt;p&gt;A simplified version of the validation looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;router.post(&quot;/book&quot;, async (req, res) =&amp;gt; {
  const {
    time,
    duration,
    email,
    firstName,
    lastName,
    description
  } = req.body;

  if (
    !time ||
    !duration ||
    !email ||
    !firstName ||
    !lastName ||
    !description
  ) {
    return res.status(400).json({
      message: &quot;Missing required fields&quot;
    });
  }

  // Continue with availability checks
  // and booking creation.
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a simplified illustration of the validation logic in my implementation, not a complete replacement for the original endpoint.&lt;/p&gt;
&lt;p&gt;The actual endpoint validates additional fields, including the selected date, day, time range, phone number, and referral information.&lt;/p&gt;
&lt;p&gt;The important principle is to validate the data required by the booking workflow before creating external resources or writing records.&lt;/p&gt;
&lt;p&gt;For a production system, request validation should also verify data types, valid email addresses, positive durations, valid dates, and acceptable appointment times. A field being present does not necessarily mean its value is valid.&lt;/p&gt;
&lt;h3 id=&quot;3-calculating-appointment-duration&quot;&gt;3. Calculating Appointment Duration&lt;/h3&gt;
&lt;p&gt;A booking needs both a start time and an end time. The end time determines the appointment's actual interval and makes it possible to detect overlapping bookings.&lt;/p&gt;
&lt;p&gt;In my implementation, the backend calculates the end time using the requested start time and duration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const startTime = new Date(time);
const endTime = new Date(startTime);

endTime.setMinutes(
  endTime.getMinutes() + Number(duration)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For example, an appointment starting at 10:00 AM with a duration of 60 minutes should end at 11:00 AM.&lt;/p&gt;
&lt;p&gt;Calculating the interval on the backend is important because scheduling decisions should not depend solely on values calculated by the browser.&lt;/p&gt;
&lt;p&gt;It is also important to handle time zones consistently. My Google Calendar integration sends ISO-formatted timestamps to the API. A more comprehensive implementation should explicitly define the application's scheduling time zone and validate incoming timestamps to prevent ambiguity around local times and daylight-saving transitions.&lt;/p&gt;
&lt;h3 id=&quot;4-preventing-overlapping-bookings&quot;&gt;4. Preventing Overlapping Bookings&lt;/h3&gt;
&lt;p&gt;One of the central problems in a booking system is preventing customers from selecting times that have already been reserved.&lt;/p&gt;
&lt;p&gt;Two appointments overlap when the first appointment starts before the second one ends and finishes after the second one starts.&lt;/p&gt;
&lt;p&gt;The logic can be expressed as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const overlaps =
  requestedStart &amp;lt; existingEnd &amp;amp;&amp;amp;
  requestedEnd &amp;gt; existingStart;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This condition also allows back-to-back appointments. For example, a booking ending at 11:00 AM does not conflict with another booking beginning at exactly 11:00 AM.&lt;/p&gt;
&lt;p&gt;In my implementation, I retrieve existing bookings from MongoDB, calculate each booking's end time from its start time and duration, and check for conflicts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const existingBookings = await Booking.find();

const conflict = existingBookings.some(booking =&amp;gt; {
  const existingStart = new Date(booking.startTime);
  const existingEnd = new Date(existingStart);

  existingEnd.setMinutes(
    existingEnd.getMinutes() +
      Number(booking.duration)
  );

  return (
    startTime &amp;lt; existingEnd &amp;amp;&amp;amp;
    endTime &amp;gt; existingStart
  );
});

if (conflict) {
  return res.status(409).json({
    message: &quot;This time slot has already been booked&quot;
  });
}`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Returning HTTP &lt;code&gt;&quot;409 Conflict&quot;&lt;/code&gt; communicates that the request conflicts with an existing booking.&lt;/p&gt;
&lt;p&gt;This approach is easy to understand, but it has a scalability limitation: the implementation loads all existing bookings before checking for an overlap. As the collection grows, a database query restricted to potentially overlapping intervals would be more efficient.&lt;/p&gt;
&lt;p&gt;There is also an important concurrency consideration. Two requests can check availability at almost the same time, both find the slot free, and both continue. The check alone does not guarantee that simultaneous requests cannot create overlapping bookings.&lt;/p&gt;
&lt;p&gt;A stronger production design would use an atomic reservation strategy, such as reserving discrete time slots with a unique database constraint or using another concurrency-control mechanism. That protection needs to be designed around the application's scheduling rules rather than relying only on an initial availability check.&lt;/p&gt;
&lt;h3 id=&quot;5-connecting-google-calendar-and-google-meet&quot;&gt;5. Connecting Google Calendar and Google Meet&lt;/h3&gt;
&lt;p&gt;After the backend confirms that a requested slot does not conflict with an existing booking, it calls a dedicated utility function to create the meeting.&lt;/p&gt;
&lt;p&gt;I separated this logic into a Google Calendar utility instead of placing the entire integration directly inside the Express route. This keeps the route focused on the booking workflow and makes the external integration easier to maintain.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Authenticating with Google&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The utility initializes an OAuth 2.0 client using environment variables and sets a refresh token:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const { google } = require(&quot;googleapis&quot;);

const oauth2Client = new google.auth.OAuth2(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  process.env.GOOGLE_REDIRECT_URI
);

oauth2Client.setCredentials({
  refresh_token: process.env.GOOGLE_REFRESH_TOKEN
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The example uses the conventional environment-variable spelling for the client secret. In the project, the environment variable is named &lt;code&gt;&quot;GOOGLE_CLIENT_SECRETE&quot;&lt;/code&gt;, so the deployed configuration must match the actual code unless that naming is corrected.&lt;/p&gt;
&lt;p&gt;The refresh token allows the server-side client to obtain access tokens as needed without requiring the user to repeat the authorization flow for every booking.&lt;/p&gt;
&lt;p&gt;This requires the initial OAuth authorization to have been completed successfully and the refresh token to remain valid. The Google account must also have the necessary permissions and configuration for creating Calendar events and conference links.&lt;/p&gt;
&lt;p&gt;Credentials should be stored securely in environment variables or an appropriate secrets manager, never committed to a public repository.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Creating a Calendar event&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The integration uses Google's Calendar API:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const calendar = google.calendar({
  version: &quot;v3&quot;,
  auth: oauth2Client
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The meeting function calculates the event's end time and constructs a Calendar event containing the session title, description, start and end timestamps, and attendee information.&lt;/p&gt;
&lt;p&gt;The attendee is represented by the customer's email address:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;attendees: [
  {
    email,
    responseStatus: &quot;accepted&quot;
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The event also includes settings that prevent guests from modifying the event, inviting other guests, or seeing other guests.&lt;/p&gt;
&lt;p&gt;These settings are part of the event configuration; they do not replace proper authorization and access control in the booking application itself.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Requesting a Google Meet link&lt;br&gt;
&lt;/strong&gt;&lt;br&gt;
The event configuration includes a conference creation request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;conferenceData: {
  createRequest: {
    requestId: &quot;jopad-&quot; + Date.now(),
    conferenceSolutionKey: {
      type: &quot;hangoutsMeet&quot;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The event is then inserted into the primary Google Calendar:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const response = await calendar.events.insert({
  calendarId: &quot;primary&quot;,
  resource: event,
  conferenceDataVersion: 1,
  sendUpdates: &quot;all&quot;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &quot;conferenceDataVersion: 1&quot; option tells Google Calendar that conference data should be processed as part of the request. The &quot;sendUpdates: &quot;all&quot;&quot; option requests that Calendar send event updates to the relevant participants.&lt;/p&gt;
&lt;p&gt;After the API call succeeds, the implementation extracts the meeting URL from the returned conference data:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const meetLink =
  response.data.conferenceData.entryPoints[0].uri;

return meetLink;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The booking route receives that link and includes it in the booking record.&lt;/p&gt;
&lt;p&gt;One important implementation detail is that the code assumes the response contains a conference entry point. A more defensive implementation would verify that the response contains a usable Meet URL before continuing.&lt;/p&gt;
&lt;p&gt;Google Calendar conference creation can also involve asynchronous processing. Production code should account for cases where the event exists but the conference details are not yet available, rather than assuming every successful event insertion immediately includes a meeting link.&lt;/p&gt;
&lt;h3 id=&quot;6-saving-bookings-in-mongodb&quot;&gt;6. Saving Bookings in MongoDB&lt;/h3&gt;
&lt;p&gt;Once the meeting link has been returned, the backend creates a Mongoose booking document.&lt;/p&gt;
&lt;p&gt;The model stores information such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Appointment start time and duration.&lt;/li&gt;
&lt;li&gt;Customer name and contact information.&lt;/li&gt;
&lt;li&gt;Session description.&lt;/li&gt;
&lt;li&gt;Date and time display values.&lt;/li&gt;
&lt;li&gt;Referral information.&lt;/li&gt;
&lt;li&gt;Google Meet link.&lt;/li&gt;
&lt;li&gt;Record creation timestamp.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The central fields include:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const bookingSchema = new mongoose.Schema({
  startTime: {
    type: Date,
    required: true
  },
  duration: String,
  email: String,
  firstName: String,
  lastName: String,
  description: String,
  meetLink: String,
  createdAt: {
    type: Date,
    default: Date.now
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is an excerpt of the schema; the complete project model contains additional fields.&lt;/p&gt;
&lt;p&gt;After constructing the document, the route saves it:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;await newBooking.save();&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Saving the booking after generating the meeting link ensures that the returned booking record can contain the link associated with the created event.&lt;/p&gt;
&lt;p&gt;However, the order of these operations creates an important failure scenario. If Google Calendar successfully creates the event but MongoDB subsequently fails to save the booking, a calendar event may remain without a corresponding booking record.&lt;/p&gt;
&lt;p&gt;This is a common challenge when coordinating operations across independent services. A more resilient design could track workflow states, retry recoverable operations, and provide a reconciliation or cleanup process for partially completed bookings.&lt;/p&gt;
&lt;h3 id=&quot;7-adding-an-airtable-backup&quot;&gt;7. Adding an Airtable Backup&lt;/h3&gt;
&lt;p&gt;The system also attempts to send booking information to Airtable after saving the MongoDB document.&lt;/p&gt;
&lt;p&gt;The request includes selected booking fields, such as the customer's name, email, phone number, session description, referral details, meeting link, and appointment date.&lt;/p&gt;
&lt;p&gt;The operation is wrapped in its own &quot;try...catch&quot; block. If the Airtable request fails, the error is logged, but the main booking workflow can still continue.&lt;/p&gt;
&lt;p&gt;This makes Airtable a secondary destination rather than the primary source of truth for the booking.&lt;/p&gt;
&lt;p&gt;That distinction matters: the implementation attempts a backup write, but it does not guarantee that the MongoDB and Airtable records are always synchronized. If Airtable is unavailable, the backup attempt fails without automatically retrying it.&lt;/p&gt;
&lt;p&gt;A more robust implementation could place failed backup operations into a retry queue or maintain a record of synchronization status.&lt;/p&gt;
&lt;h3 id=&quot;8-sending-booking-notifications&quot;&gt;8. Sending Booking Notifications&lt;/h3&gt;
&lt;p&gt;After the booking has been saved and the Airtable attempt has completed, the route triggers the email notification utility:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;sendBookingEmails({
  email,
  firstName,
  lastName,
  dayString,
  referral,
  dateString,
  number,
  description,
  timeString,
  timeRange,
  meetLink
}).catch(err =&amp;gt; {
  console.log(&quot;Email failed:&quot;, err.message);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The email operation is not awaited by the route. This allows the endpoint to return its successful booking response without waiting for the notification workflow to finish.&lt;/p&gt;
&lt;p&gt;It also means an email failure does not change the response after the booking has been saved.&lt;/p&gt;
&lt;p&gt;There is a trade-off: the current implementation logs email failures but does not automatically retry them or record a delivery status in the booking document. For a production booking service, a persistent background job or message queue would provide a stronger way to retry notifications and monitor delivery outcomes.&lt;/p&gt;
&lt;p&gt;The route eventually responds with HTTP &lt;code&gt;&quot;201 Created&quot;&lt;/code&gt;, along with a success message and the saved booking.&lt;/p&gt;
&lt;h3 id=&quot;9-lessons-from-the-implementation&quot;&gt;9. Lessons from the Implementation&lt;/h3&gt;
&lt;p&gt;Building this workflow brought several backend engineering considerations into focus.&lt;/p&gt;
&lt;p&gt;Validate before creating external resources. Checking the request and current bookings before calling Google reduces unnecessary API operations.&lt;/p&gt;
&lt;p&gt;Separate responsibilities. Keeping the Calendar integration in its own utility makes the Express route easier to understand and maintain.&lt;/p&gt;
&lt;p&gt;Handle partial failures. MongoDB, Google Calendar, Airtable, and an email provider are independent services. A successful operation in one does not guarantee success in the others.&lt;/p&gt;
&lt;p&gt;Treat concurrency as a separate problem. Checking for overlapping bookings is necessary, but it does not by itself protect against two simultaneous requests reserving the same slot.&lt;/p&gt;
&lt;p&gt;Design for observability and recovery. Logging an error helps with debugging, but production systems also benefit from structured logs, retry mechanisms, delivery tracking, and reconciliation workflows.&lt;/p&gt;
&lt;p&gt;These considerations extend beyond booking applications. Any backend that coordinates a database with multiple external APIs needs a plan for partial failures and retries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Building the JOPAD Advisory booking system from scratch gave me practical experience connecting a Node.js backend to Google Calendar, generating Google Meet links, storing bookings in MongoDB, maintaining a secondary Airtable record, and triggering email notifications.&lt;/p&gt;
&lt;p&gt;The most important lesson is that integrating APIs is only one part of building a reliable application. The surrounding workflow—validation, conflict detection, persistence, failure handling, and recovery—is just as important.&lt;/p&gt;
&lt;p&gt;A booking endpoint can appear simple from the frontend, but it coordinates several systems that can succeed or fail independently. Understanding those interactions is what turns an API integration into a more complete engineering solution.&lt;/p&gt;
&lt;p&gt;I'm continuing to explore ways to make these workflows more resilient, especially around concurrent bookings, retries, and recovery from partial failures.&lt;/p&gt;
&lt;p&gt;If you're building a booking platform or integrating third-party services into a Node.js application, I'd be interested to hear how you approach these challenges.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32649/building-a-booking-system-with-node-js-google-calendar-and-google-meet</guid>
<pubDate>Tue, 27 Oct 2026 07:58:12 +0000</pubDate>
</item>
<item>
<title>Building Production Backends with NestJS</title>
<link>https://coderlegion.com/32588/building-production-backends-with-nestjs</link>
<description>&lt;p&gt;My focus for this month of October will be on NestJS framework and how it has imparted my development experience as a fullstack software engineer. NestJS provides a structured framework for building Node.js applications, but the framework alone does not determine whether an application will be maintainable in production.&lt;/p&gt;
&lt;p&gt;The architecture surrounding the framework matters a lot. I will be sharing overview on my opinions when developing a secure and scalable systems in NestJS.&lt;/p&gt;
&lt;h3 id=&quot;feature-oriented-modules&quot;&gt;Feature-oriented modules&lt;/h3&gt;
&lt;p&gt;I prefer organizing backend systems around business capabilities rather than technical file types.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;src/
├── modules/
│   ├── users/
│   ├── authentication/
│   ├── payments/
│   └── notifications/
│
├── infrastructure/
├── shared/
└── main.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This keeps related business functionality together.&lt;/p&gt;
&lt;h3 id=&quot;dependency-boundaries&quot;&gt;Dependency boundaries&lt;/h3&gt;
&lt;p&gt;Business logic should not directly depend on infrastructure details.&lt;/p&gt;
&lt;p&gt;For example, application code should depend on an abstraction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export interface EmailSender {
  send(message: EmailMessage): Promise&amp;lt;void&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;rather than directly depending on a specific provider.&lt;/p&gt;
&lt;p&gt;The infrastructure layer can then implement it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export class ResendEmailSender implements EmailSender {
  async send(message: EmailMessage): Promise&amp;lt;void&amp;gt; {
    // provider implementation
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This makes it possible to replace infrastructure without rewriting business logic.&lt;/p&gt;
&lt;h3 id=&quot;the-goal&quot;&gt;The goal&lt;/h3&gt;
&lt;p&gt;The goal is not to create the largest possible architecture.&lt;/p&gt;
&lt;p&gt;The goal is to create a system where important decisions remain easy to change.&lt;/p&gt;
&lt;p&gt;That principle becomes increasingly important as an application grows.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32588/building-production-backends-with-nestjs</guid>
<pubDate>Mon, 26 Oct 2026 19:06:12 +0000</pubDate>
</item>
<item>
<title>Limpando ~20 GB de caches de Terraform e Node.js com um único comando Bash</title>
<link>https://coderlegion.com/32582/limpando-20-gb-de-caches-de-terraform-e-node-js-com-um-unico-comando-bash</link>
<description>&lt;p&gt;Mantenho todos os meus repositórios em uma única pasta — cerca de 150, entre trabalho para clientes, templates e experimentos. Cada um deles é uma pequena armadilha: um &lt;code&gt;node_modules&lt;/code&gt; dentro de &lt;code&gt;app/&lt;/code&gt; e um &lt;code&gt;.terraform&lt;/code&gt; dentro de &lt;code&gt;infra/terraform/&lt;/code&gt;. Nenhum dos dois é versionado, ambos são reconstruídos em segundos (bem, minutos), e juntos tinham comido mais de 20 GB do meu disco sem que eu percebesse.&lt;/p&gt;
&lt;p&gt;O gatilho foi banal: &lt;code&gt;df -h&lt;/code&gt; mostrando 94% de uso numa partição de 233 GB, com 10 GB livres. A correção também deveria ser banal. Em vez de rodar um &lt;code&gt;find ... -exec rm -rf&lt;/code&gt; do qual eu me arrependeria, escrevi um comando pequeno em que eu pudesse confiar — e mantive.&lt;/p&gt;
&lt;p&gt;Este artigo traz o script, o raciocínio por trás de cada proteção e o que aprendi ao rodá-lo.&lt;/p&gt;
&lt;h3 id=&quot;o-objetivo&quot;&gt;O objetivo&lt;/h3&gt;
&lt;p&gt;Um comando, de qualquer lugar, que:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Varra &lt;code&gt;~/Documentos/github&lt;/code&gt; (em qualquer profundidade — incluindo monorepos e pastas agrupadas).&lt;/li&gt;
&lt;li&gt;Encontre &lt;strong&gt;apenas&lt;/strong&gt; dois tipos de diretório:&lt;ul&gt;
&lt;li&gt;&lt;code&gt;*/infra/terraform/.terraform&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;*/app/node_modules&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Informe o tamanho de cada um e os totais.&lt;/li&gt;
&lt;li&gt;Delete esses diretórios — mas somente quando isso for pedido explicitamente.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;decis-es-de-projeto&quot;&gt;Decisões de projeto&lt;/h3&gt;
&lt;p&gt;1. Dry-run como padrão: apagar cache de build é reversível, mas executar a operação em 30 diretórios distribuídos por 150 repositórios exige cautela. O parâmetro &lt;code&gt;--delete&lt;/code&gt; é obrigatório para efetivar as exclusões.&lt;/p&gt;
&lt;p&gt;2. &lt;code&gt;find -prune&lt;/code&gt; em vez de &lt;code&gt;du&lt;/code&gt; recursivo: evita percorrer diretórios como &lt;code&gt;.git&lt;/code&gt;, &lt;code&gt;node_modules&lt;/code&gt; e &lt;code&gt;.terraform&lt;/code&gt;, mantendo a varredura eficiente mesmo com milhares de arquivos.&lt;/p&gt;
&lt;p&gt;3. Whitelist de caminhos antes de apagar: o &lt;code&gt;rm -rf&lt;/code&gt; só é executado em caminhos que correspondem exatamente aos sufixos permitidos e estão dentro da raiz varrida. Qualquer caminho fora dessas regras é ignorado e reportado.&lt;/p&gt;
&lt;p&gt;4. Medição de tamanho em paralelo: executa o &lt;code&gt;du&lt;/code&gt; em paralelo usando &lt;code&gt;xargs -P &quot;$(nproc)&quot;&lt;/code&gt;, aproveitando os núcleos disponíveis e reduzindo o tempo de execução em árvores grandes.&lt;/p&gt;
&lt;p&gt;5. Saída legível, cálculos precisos: os tamanhos são somados em bytes e formatados com &lt;code&gt;numfmt&lt;/code&gt;, evitando divergências causadas pela soma de valores arredondados.&lt;/p&gt;
&lt;p&gt;6. Espaço livre antes e depois: compara o espaço disponível no disco antes e depois da limpeza para verificar o ganho real obtido.&lt;/p&gt;
&lt;p&gt;Um detalhe que vale destacar: &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; é um &lt;strong&gt;arquivo&lt;/strong&gt; e &lt;em&gt;não&lt;/em&gt; é tocado. Ele fixa as versões dos providers e deve ser versionado. Apenas o diretório &lt;code&gt;.terraform/&lt;/code&gt; (cache de providers e módulos) é removido.&lt;/p&gt;
&lt;h3 id=&quot;o-script&quot;&gt;O script&lt;/h3&gt;
&lt;p&gt;Salve como &lt;code&gt;limpar-cache&lt;/code&gt;, dê permissão de execução e coloque em &lt;code&gt;/usr/local/bin&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
#
# limpar-cache — varre um workspace e levanta/remove:
#   * &amp;lt;projeto&amp;gt;/infra/terraform/.terraform
#   * &amp;lt;projeto&amp;gt;/app/node_modules
#
# Uso:
#   limpar-cache                 # só relatório (dry-run, nada é apagado)
#   limpar-cache --delete        # apaga, pedindo confirmação
#   limpar-cache --delete --yes  # apaga sem confirmação
#   limpar-cache --only node     # apenas node_modules
#   limpar-cache --only terraform
#   limpar-cache --base ~/outro/dir
#

set -uo pipefail

BASE=&quot;${HOME}/Documentos/github&quot;
ONLY=&quot;all&quot;
DO_DELETE=0
ASSUME_YES=0
JOBS=&quot;$(nproc 2&amp;gt;/dev/null || echo 4)&quot;

usage() {
  cat &amp;lt;&amp;lt;'EOF'
limpar-cache — relatório e limpeza de caches em ~/Documentos/github

Alvos:
  */infra/terraform/.terraform
  */app/node_modules

Opções:
  -d, --delete         remove os diretórios encontrados (com confirmação)
  -y, --yes            não pede confirmação (implica --delete)
  -n, --dry-run        apenas relatório (padrão)
      --only TIPO      all (padrão) | terraform | node
      --base DIR       raiz a varrer (padrão: ~/Documentos/github)
  -h, --help           esta ajuda

Exemplos:
  limpar-cache
  limpar-cache --only terraform
  limpar-cache -d -y
EOF
}

while (($#)); do
  case &quot;$1&quot; in
    -d | --delete) DO_DELETE=1 ;;
    -y | --yes) ASSUME_YES=1 ;;
    -n | --dry-run) DO_DELETE=0 ;;
    --only)
      ONLY=&quot;${2:-}&quot;
      shift
      ;;
    --base)
      BASE=&quot;${2:-}&quot;
      shift
      ;;
    -h | --help)
      usage
      exit 0
      ;;
    *)
      printf 'limpar-cache: opção inválida: %s\n\n' &quot;$1&quot; &amp;gt;&amp;amp;2
      usage &amp;gt;&amp;amp;2
      exit 2
      ;;
  esac
  shift
done

case &quot;$ONLY&quot; in
  all | terraform | node) ;;
  *)
    printf 'limpar-cache: --only deve ser all, terraform ou node\n' &amp;gt;&amp;amp;2
    exit 2
    ;;
esac

((ASSUME_YES)) &amp;amp;&amp;amp; DO_DELETE=1

if [[ ! -d &quot;$BASE&quot; ]]; then
  printf 'limpar-cache: diretório não encontrado: %s\n' &quot;$BASE&quot; &amp;gt;&amp;amp;2
  exit 1
fi

BASE=&quot;$(realpath -m -- &quot;$BASE&quot;)&quot;

human() { numfmt --to=iec-i --suffix=B --format='%.1f' &quot;$1&quot; 2&amp;gt;/dev/null || printf '%sB' &quot;$1&quot;; }

avail_bytes() { df -B1 --output=avail -- &quot;$BASE&quot; 2&amp;gt;/dev/null | tail -1 | tr -d ' '; }

# ---------------------------------------------------------------- coleta -----
# -prune evita descer em .git, node_modules e .terraform (rápido mesmo com 150 repos)
TF_DIRS=()
NODE_DIRS=()
while IFS= read -r -d '' d; do
  case &quot;$d&quot; in
    */infra/terraform/.terraform) TF_DIRS+=(&quot;$d&quot;) ;;
    */app/node_modules) NODE_DIRS+=(&quot;$d&quot;) ;;
  esac
done &amp;lt; &amp;lt;(find &quot;$BASE&quot; -mindepth 2 \( -name .git -o -name node_modules -o -name .terraform \) -prune -print0 2&amp;gt;/dev/null)

[[ &quot;$ONLY&quot; == &quot;node&quot; ]] &amp;amp;&amp;amp; TF_DIRS=()
[[ &quot;$ONLY&quot; == &quot;terraform&quot; ]] &amp;amp;&amp;amp; NODE_DIRS=()

if ((${#TF_DIRS[@]} == 0 &amp;amp;&amp;amp; ${#NODE_DIRS[@]} == 0)); then
  printf 'Nada encontrado em %s (filtro: %s).\n' &quot;$BASE&quot; &quot;$ONLY&quot;
  exit 0
fi

TMP=&quot;$(mktemp)&quot;
trap 'rm -f &quot;$TMP&quot;' EXIT

# tamanho aparente em paralelo: &quot;tipo&amp;lt;TAB&amp;gt;bytes&amp;lt;TAB&amp;gt;caminho&quot;
sizes_of() {
  local kind=&quot;$1&quot;
  shift
  (($#)) || return 0
  printf '%s\0' &quot;$@&quot; |
    xargs -0 -r -n1 -P &quot;$JOBS&quot; bash -c '
      p=&quot;$1&quot;
      s=&quot;$(du -sb -- &quot;$p&quot; 2&amp;gt;/dev/null | cut -f1)&quot;
      [[ -n &quot;$s&quot; ]] || s=0
      printf &quot;%s\t%s\t%s\n&quot; &quot;$0&quot; &quot;$s&quot; &quot;$p&quot;' &quot;$kind&quot;
}

{
  sizes_of terraform &quot;${TF_DIRS[@]+&quot;${TF_DIRS[@]}&quot;}&quot;
  sizes_of node &quot;${NODE_DIRS[@]+&quot;${NODE_DIRS[@]}&quot;}&quot;
} &amp;gt;&quot;$TMP&quot;

sort -t $'\t' -k2,2nr -o &quot;$TMP&quot; &quot;$TMP&quot;

total_tf=&quot;$(awk -F'\t' '$1==&quot;terraform&quot;{s+=$2} END{print s+0}' &quot;$TMP&quot;)&quot;
total_node=&quot;$(awk -F'\t' '$1==&quot;node&quot;{s+=$2} END{print s+0}' &quot;$TMP&quot;)&quot;
total_all=&quot;$((total_tf + total_node))&quot;
count_tf=&quot;${#TF_DIRS[@]}&quot;
count_node=&quot;${#NODE_DIRS[@]}&quot;

# ------------------------------------------------------------- relatório -----
printf '\nRaiz: %s\n' &quot;$BASE&quot;
printf '%-9s %12s  %s\n' &quot;TIPO&quot; &quot;TAMANHO&quot; &quot;CAMINHO&quot;
printf '%-9s %12s  %s\n' &quot;--------&quot; &quot;-----------&quot; &quot;----------------------------------------&quot;
while IFS=$'\t' read -r kind bytes path; do
  printf '%-9s %12s  %s\n' &quot;$kind&quot; &quot;$(human &quot;$bytes&quot;)&quot; &quot;${path#&quot;$BASE&quot;/}&quot;
done &amp;lt;&quot;$TMP&quot;

printf '\nResumo\n'
printf '  terraform/.terraform : %3d diretório(s)  %10s\n' &quot;$count_tf&quot; &quot;$(human &quot;$total_tf&quot;)&quot;
printf '  app/node_modules     : %3d diretório(s)  %10s\n' &quot;$count_node&quot; &quot;$(human &quot;$total_node&quot;)&quot;
printf '  TOTAL                : %3d diretório(s)  %10s\n' &quot;$((count_tf + count_node))&quot; &quot;$(human &quot;$total_all&quot;)&quot;
printf '  Livre em disco agora : %10s\n' &quot;$(human &quot;$(avail_bytes)&quot;)&quot;

if ((DO_DELETE == 0)); then
  printf '\nModo relatório (nada foi apagado). Para remover: %s --delete\n\n' &quot;$(basename &quot;$0&quot;)&quot;
  exit 0
fi

# --------------------------------------------------------------- remoção -----
if ((ASSUME_YES == 0)); then
  printf '\nRemover %d diretório(s), liberando ~%s? [s/N] ' &quot;$((count_tf + count_node))&quot; &quot;$(human &quot;$total_all&quot;)&quot;
  read -r reply
  case &quot;$reply&quot; in
    s | S | y | Y | sim | SIM) ;;
    *)
      printf 'Cancelado.\n\n'
      exit 1
      ;;
  esac
fi

is_safe() {
  local p=&quot;$1&quot; real
  [[ -n &quot;$p&quot; ]] || return 1
  [[ &quot;$p&quot; != &quot;/&quot; &amp;amp;&amp;amp; &quot;$p&quot; != &quot;$HOME&quot; ]] || return 1
  case &quot;$p&quot; in
    */infra/terraform/.terraform | */app/node_modules) ;;
    *) return 1 ;;
  esac
  real=&quot;$(realpath -m -- &quot;$p&quot; 2&amp;gt;/dev/null)&quot; || return 1
  [[ &quot;$real&quot; == &quot;$BASE&quot;/* ]] || return 1
  [[ &quot;$real&quot; != &quot;$BASE&quot; ]] || return 1
  return 0
}

avail_before=&quot;$(avail_bytes)&quot;
removed=0
failed=0

while IFS=$'\t' read -r kind bytes path; do
  if ! is_safe &quot;$path&quot;; then
    printf '  IGNORADO (fora do padrão/raiz): %s\n' &quot;$path&quot;
    ((failed++))
    continue
  fi
  if rm -rf -- &quot;$path&quot; 2&amp;gt;/dev/null &amp;amp;&amp;amp; [[ ! -e &quot;$path&quot; ]]; then
    printf '  removido  %10s  %s\n' &quot;$(human &quot;$bytes&quot;)&quot; &quot;${path#&quot;$BASE&quot;/}&quot;
    ((removed++))
  else
    printf '  FALHOU    %10s  %s\n' &quot;$(human &quot;$bytes&quot;)&quot; &quot;${path#&quot;$BASE&quot;/}&quot; &amp;gt;&amp;amp;2
    ((failed++))
  fi
done &amp;lt;&quot;$TMP&quot;

avail_after=&quot;$(avail_bytes)&quot;
freed=$((avail_after - avail_before))
((freed &amp;lt; 0)) &amp;amp;&amp;amp; freed=0

printf '\nConcluído: %d removido(s), %d falha(s).\n' &quot;$removed&quot; &quot;$failed&quot;
printf 'Espaço livre: %s -&amp;gt; %s (~%s liberados)\n\n' \
  &quot;$(human &quot;$avail_before&quot;)&quot; &quot;$(human &quot;$avail_after&quot;)&quot; &quot;$(human &quot;$freed&quot;)&quot;

((failed == 0)) || exit 1
exit 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instale e deixe disponível em todo shell:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;install -m 755 limpar-cache /usr/local/bin/limpar-cache
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;E no &lt;code&gt;~/.bashrc&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export PATH=&quot;/usr/local/bin:$PATH&quot;
alias lcache='limpar-cache'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A linha explícita de &lt;code&gt;PATH&lt;/code&gt; é redundante na maioria das distribuições, mas não custa nada e deixa a configuração à prova de shells de login esquisitos. (Ela já me salvou uma vez — veja a última lição.)&lt;/p&gt;
&lt;h3 id=&quot;rodando&quot;&gt;Rodando&lt;/h3&gt;
&lt;p&gt;Relatório primeiro, sempre:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ lcache

Raiz: /home/user/Documentos/github
TIPO           TAMANHO  CAMINHO
--------   -----------  ----------------------------------------
node            1,4GiB  projeto-alfa/app/node_modules
node            1,1GiB  projeto-beta/app/node_modules
terraform     870,7MiB  projeto-alfa/infra/terraform/.terraform
terraform     865,2MiB  projeto-gama/infra/terraform/.terraform
node          771,2MiB  projeto-delta/app/node_modules
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Depois, quando a lista parecer certa:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;lcache --delete          # pergunta: Remover 30 diretório(s), liberando ~20,7GiB? [s/N]
lcache -d -y --only node # apenas node_modules, sem prompt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Uma execução na minha máquina:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Resumo
  terraform/.terraform :   9 diretório(s)      7,1GiB
  app/node_modules     :  21 diretório(s)     13,6GiB
  TOTAL                :  30 diretório(s)     20,7GiB
  Livre em disco agora :      9,9GiB
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;30 diretórios, ~20,7 GB. O disco saiu de 94% para cerca de 86% de uso — e cada um desses repositórios é reconstruível com &lt;code&gt;npm ci&lt;/code&gt; ou &lt;code&gt;terraform init&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;li-es-aprendidas&quot;&gt;Lições aprendidas&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;-prune&lt;/code&gt; é o truque inteiro.&lt;/strong&gt; Um &lt;code&gt;find ~/Documentos/github -name node_modules&lt;/code&gt; ingênuo desce em todo &lt;code&gt;node_modules&lt;/code&gt; procurando outros aninhados. Em 150 repositórios isso são minutos de I/O inútil. Podar no momento do match transforma tudo em uma única varredura rápida.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Tamanho aparente ≠ uso de disco.&lt;/strong&gt; &lt;code&gt;du -sb&lt;/code&gt; dá bytes aparentes (rápido, comparável), enquanto &lt;code&gt;du -s&lt;/code&gt; dá blocos alocados (o que o &lt;code&gt;df&lt;/code&gt; reporta). Para um relatório de limpeza, o tamanho aparente é o número honesto e previsível; para &quot;quanto eu vou ganhar de verdade&quot;, use &lt;code&gt;--block-size=1&lt;/code&gt; sem &lt;code&gt;-b&lt;/code&gt;. Eu mostro &lt;code&gt;df --output=avail&lt;/code&gt; antes/depois justamente porque os dois diferem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. O page cache frio domina o tempo de execução.&lt;/strong&gt; A primeira execução mediu ~2m26s; a segunda foi bem mais rápida. Nada de errado com o script — são 30 árvores de diretório sendo lidas do disco pela primeira vez. Paralelismo com &lt;code&gt;xargs -P&lt;/code&gt; ajuda, mas não vence a física.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Duas cópias de um script é um bug esperando para acontecer.&lt;/strong&gt; Eu tinha instalado em &lt;code&gt;~/.local/bin&lt;/code&gt; primeiro e depois &quot;movi&quot; para &lt;code&gt;/usr/local/bin&lt;/code&gt; — mas &lt;code&gt;~/.local/bin&lt;/code&gt; vem &lt;strong&gt;antes&lt;/strong&gt; no &lt;code&gt;PATH&lt;/code&gt;, então a cópia antiga sombreava a nova silenciosamente. Correção: &lt;em&gt;mover&lt;/em&gt; de verdade (&lt;code&gt;install&lt;/code&gt; + &lt;code&gt;rm&lt;/code&gt;), não copiar. E conferir com &lt;code&gt;command -v&lt;/code&gt;, não com &lt;code&gt;which&lt;/code&gt; (que é outro binário, com outra semântica).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Proteções são baratas; erros de &lt;code&gt;rm -rf&lt;/code&gt; não.&lt;/strong&gt; A função &lt;code&gt;is_safe()&lt;/code&gt; tem ~12 linhas e rejeita &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;$HOME&lt;/code&gt;, qualquer coisa fora da raiz varrida e qualquer caminho que não termine nos dois sufixos esperados. Ela roda em cada caminho, mesmo os vindos do meu próprio &lt;code&gt;find&lt;/code&gt;. Essa redundância é o ponto: o custo é microssegundos, o modo de falha é &quot;apaguei meu diretório home&quot;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. Whitelist ganha de blacklist.&lt;/strong&gt; Eu deliberadamente &lt;em&gt;não&lt;/em&gt; apago &lt;code&gt;dist/&lt;/code&gt;, &lt;code&gt;.next/&lt;/code&gt;, &lt;code&gt;.venv/&lt;/code&gt; ou &lt;code&gt;coverage/&lt;/code&gt;. Esses têm custos de reconstrução muito diferentes e alguns são artefatos de deploy. Se eu precisar deles depois, cada um ganha seu sufixo explícito no &lt;code&gt;case&lt;/code&gt; — nunca uma regra de &quot;apague tudo que pareça cache&quot;.&lt;/p&gt;
&lt;h3 id=&quot;checklist-de-recupera-o&quot;&gt;Checklist de recuperação&lt;/h3&gt;
&lt;p&gt;Depois de uma limpeza dessas, cada repositório precisa de uma coisa:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Projetos Node
cd app &amp;amp;&amp;amp; npm ci          # ou pnpm install / yarn install

# Projetos Terraform
cd infra/terraform &amp;amp;&amp;amp; terraform init
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;O &lt;code&gt;terraform init&lt;/code&gt; baixa os providers novamente; se você usa cache de plugins (&lt;code&gt;TF_PLUGIN_CACHE_DIR&lt;/code&gt;), será bem mais rápido da próxima vez — vale configurar se costuma limpar &lt;code&gt;.terraform&lt;/code&gt; com frequência.&lt;/p&gt;
&lt;h3 id=&quot;considera-es-finais&quot;&gt;Considerações finais&lt;/h3&gt;
&lt;p&gt;O script tem cerca de 200 linhas, a maioria delas &lt;code&gt;printf&lt;/code&gt;. O que o torna seguro não é sofisticação, e sim um punhado de escolhas entediantes: dry-run primeiro, podar cedo, whitelist de caminhos, confirmar antes de apagar e imprimir o que realmente aconteceu.&lt;/p&gt;
&lt;p&gt;Vinte gigabytes não é muito em termos absolutos, mas era 60% do espaço livre que eu tinha. Mais importante: o comando agora existe. Na próxima vez que o disco apertar, é uma linha em vez de uma tarde de &lt;code&gt;du&lt;/code&gt; e dúvidas.&lt;/p&gt;
&lt;p&gt;Se você mantém muitos repositórios num só lugar, o padrão se generaliza — troque os dois sufixos pelos seus próprios artefatos de build, mantenha o dry-run como padrão e nunca deixe o script adivinhar o que deve apagar.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32582/limpando-20-gb-de-caches-de-terraform-e-node-js-com-um-unico-comando-bash</guid>
<pubDate>Mon, 26 Oct 2026 18:03:24 +0000</pubDate>
</item>
<item>
<title>Your Pagination Is Showing Users the Same Item Twice (and Skipping Others). Here's Why Offset Breaks</title>
<link>https://coderlegion.com/32501/your-pagination-showing-users-the-same-item-twice-and-skipping-others-heres-offset-breaks</link>
<description>&lt;p&gt;A user scrolls through your feed. Page 1 ends with &quot;Post 20&quot;. They tap &quot;Next&quot;, and page 2 starts with... &quot;Post 20&quot; again.&lt;/p&gt;
&lt;p&gt;Another user pages through an admin table and never sees order #4817. It exists. It is in the database. It just never showed up on any page.&lt;/p&gt;
&lt;p&gt;Nobody changed any code. The data changed underneath the pagination, and your &lt;code&gt;skip&lt;/code&gt; and &lt;code&gt;limit&lt;/code&gt; could not cope.&lt;/p&gt;
&lt;h3 id=&quot;the-code-everyone-writes&quot;&gt;The Code Everyone Writes&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;app.get('/api/posts', async (req, res) =&amp;gt; {
  const page = Number(req.query.page) || 1;
  const limit = 20;

  const posts = await Post.find()
    .sort({ createdAt: -1 })
    .skip((page - 1) * limit)
    .limit(limit);

  res.json(posts);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is simple, it works in development, and &lt;code&gt;?page=2&lt;/code&gt; is easy to understand. That is why it is everywhere.&lt;/p&gt;
&lt;h3 id=&quot;why-it-breaks&quot;&gt;Why It Breaks&lt;/h3&gt;
&lt;p&gt;Offset pagination says &quot;skip the first N rows, then give me the next 20&quot;. It counts positions, and positions move when the data does.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Duplicates.&lt;/strong&gt; Someone publishes a new post while a reader is on page 1. Every existing post shifts down one position. When the reader requests page 2, the last item of page 1 is now the first item of page 2.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Skips.&lt;/strong&gt; The reverse: a post is deleted while the reader is on page 1. Everything shifts up one, and the first item of what would have been page 2 slides into page 1's range. The reader never sees it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Before:  [1 2 3 4 5 | 6 7 8 9 10]    page 1 = 1-5, page 2 = 6-10
New item arrives at the top:
After:   [0 1 2 3 4 | 5 6 7 8 9]     page 2 now starts with 5, which they already saw
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-second-problem-it-gets-slower-on-deep-pages&quot;&gt;The Second Problem: It Gets Slower on Deep Pages&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;skip(100000)&lt;/code&gt; does not jump to row 100,000. The database has to walk past 100,000 documents and throw them away before returning your 20. Page 1 is fast. Page 5,000 is slow, and the cost grows with the page number.&lt;/p&gt;
&lt;p&gt;This is why a feed that feels fine with 1,000 rows slows down quietly at a million.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-cursor-pagination&quot;&gt;The Fix: Cursor Pagination&lt;/h3&gt;
&lt;p&gt;Instead of &quot;skip N&quot;, say &quot;give me the 20 items &lt;strong&gt;after this one&lt;/strong&gt;&quot;. The anchor is a &lt;strong&gt;cursor&lt;/strong&gt;: a value that identifies where the last page ended.&lt;/p&gt;
&lt;p&gt;A new item arriving at the top cannot shift your place, because you are not counting positions. You are standing on a specific item.&lt;/p&gt;
&lt;h4 id=&quot;step-1-sort-by-something-stable-with-a-tie-breaker&quot;&gt;Step 1: Sort by something stable, with a tie-breaker&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;createdAt&lt;/code&gt; alone is not enough, because two documents can share the same timestamp. Add &lt;code&gt;_id&lt;/code&gt; as a tie-breaker so the order is always exact:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;.sort({ createdAt: -1, _id: -1 })
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-2-support-the-query-with-an-index&quot;&gt;Step 2: Support the query with an index&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;PostSchema.index({ createdAt: -1, _id: -1 });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this index, the database jumps straight to the cursor and reads 20 documents, whether you are on page 1 or page 5,000.&lt;/p&gt;
&lt;h4 id=&quot;step-3-query-after-the-cursor-&quot;&gt;Step 3: Query &quot;after the cursor&quot;&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;app.get('/api/posts', async (req, res) =&amp;gt; {
  const limit = 20;
  const cursor = req.query.cursor ? decodeCursor(req.query.cursor) : null;

  const filter = cursor
    ? {
        $or: [
          { createdAt: { $lt: cursor.createdAt } },
          { createdAt: cursor.createdAt, _id: { $lt: cursor._id } },
        ],
      }
    : {};

  const rows = await Post.find(filter)
    .sort({ createdAt: -1, _id: -1 })
    .limit(limit + 1); // fetch one extra to know if there is a next page

  const hasMore = rows.length &amp;gt; limit;
  const items = hasMore ? rows.slice(0, limit) : rows;
  const last = items[items.length - 1];

  res.json({
    items,
    nextCursor: hasMore ? encodeCursor(last) : null,
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;$or&lt;/code&gt; says: &quot;older than the cursor, or the same timestamp but a smaller &lt;code&gt;_id&lt;/code&gt;&quot;. That second branch is what stops items with identical timestamps from being skipped or repeated.&lt;/p&gt;
&lt;h4 id=&quot;step-4-encode-the-cursor-so-clients-do-not-depend-on-it&quot;&gt;Step 4: Encode the cursor so clients do not depend on it&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const encodeCursor = (doc) =&amp;gt;
  Buffer.from(JSON.stringify({ createdAt: doc.createdAt, _id: doc._id })).toString('base64url');

const decodeCursor = (str) =&amp;gt; {
  const { createdAt, _id } = JSON.parse(Buffer.from(str, 'base64url').toString());
  return { createdAt: new Date(createdAt), _id: new mongoose.Types.ObjectId(_id) };
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Clients treat it as an opaque string and send it back as it is. You can change what is inside later without breaking anyone. In production, validate the decoded values and handle a malformed cursor with a &lt;code&gt;400&lt;/code&gt;, not a crash.&lt;/p&gt;
&lt;h3 id=&quot;what-you-give-up&quot;&gt;What You Give Up&lt;/h3&gt;
&lt;p&gt;Cursor pagination has a trade-off, and you should know it before switching:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No &quot;jump to page 47&quot;.&lt;/strong&gt; You can only go forward (or back, with a second cursor). For feeds and infinite scroll, that is perfect. For a numbered page bar, it is not.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No cheap total count.&lt;/strong&gt; &lt;code&gt;countDocuments&lt;/code&gt; on a large collection is not free either way, so many apps drop the &quot;page 3 of 500&quot; display.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;when-offset-is-still-fine&quot;&gt;When Offset Is Still Fine&lt;/h3&gt;
&lt;p&gt;Do not rewrite everything. Offset pagination is a reasonable choice for small, slow-changing tables, like an admin list with a few hundred rows, where users really do want page numbers.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Situation &lt;/th&gt;&lt;th&gt;Use&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Feed, timeline, infinite scroll &lt;/td&gt;&lt;td&gt;Cursor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Large collection, deep pages &lt;/td&gt;&lt;td&gt;Cursor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Data changes while people browse &lt;/td&gt;&lt;td&gt;Cursor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Small admin table with page numbers &lt;/td&gt;&lt;td&gt;Offset is fine&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;the-rule&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Offset pagination counts positions, and positions change. Cursor pagination anchors to a specific item, so it stays correct when data is added or removed.&lt;/strong&gt; Always sort by a unique, indexed combination, and fetch one extra row to know whether there is a next page.&lt;/p&gt;
&lt;p&gt;Quick audit: search your project for &lt;code&gt;.skip(&lt;/code&gt;. For each hit, ask whether the collection can grow past a few thousand documents, or change while someone is paging through it.&lt;/p&gt;
&lt;p&gt;If you work with MongoDB, I collect more patterns like this, around indexes and queries, in my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com/blog/mongodb-mongoose-patterns-2026&quot;&gt;MongoDB and Mongoose patterns guide&lt;/a&gt;, and you can browse my Next.js &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;templates on pixelanas.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Have you ever been bitten by duplicate or missing items in a paginated list? Tell me how you noticed in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32501/your-pagination-showing-users-the-same-item-twice-and-skipping-others-heres-offset-breaks</guid>
<pubDate>Mon, 26 Oct 2026 11:11:24 +0000</pubDate>
</item>
<item>
<title>Your 'Pay Now' Button Can Charge Twice. Idempotency Keys Are the Fix Nobody Adds Until It Happens</title>
<link>https://coderlegion.com/32382/your-pay-now-button-can-charge-twice-idempotency-keys-are-the-fix-nobody-adds-until-happens</link>
<description>&lt;p&gt;A customer clicks &quot;Pay now&quot;. Nothing happens for three seconds, so they click again. Or their phone loses signal for a moment and the app quietly retries the request. Or your load balancer times out and sends the request again.&lt;/p&gt;
&lt;p&gt;Your server received the same order twice, and it did exactly what you told it to do: it created two orders.&lt;/p&gt;
&lt;p&gt;This is not a rare edge case. It is the default behavior of every &lt;code&gt;POST&lt;/code&gt; endpoint you have ever written.&lt;/p&gt;
&lt;h3 id=&quot;the-endpoint-that-looks-fine&quot;&gt;The Endpoint That Looks Fine&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;app.post('/api/orders', requireAuth, async (req, res) =&amp;gt; {
  const order = await createOrder(req.user.id, req.body);
  await chargeCard(order);
  res.status(201).json({ orderId: order.id });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing is wrong with this code in isolation. The problem is what it cannot know: whether &lt;em&gt;this&lt;/em&gt; request is new, or a repeat of one that already succeeded.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-hard-to-catch&quot;&gt;Why This Is Hard to Catch&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;You test with one request at a time.&lt;/strong&gt; The bug needs two requests with the same intent, close together.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Timeouts are ambiguous.&lt;/strong&gt; When a client times out, it does not know if the server never got the request, or did the work and the response got lost. Retrying is the only safe-looking choice, and it is the one that duplicates.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disabling the button is not enough.&lt;/strong&gt; It helps with double clicks but not with network retries, mobile apps, or another service calling your API.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-idea-same-key-same-result&quot;&gt;The Idea: Same Key, Same Result&lt;/h3&gt;
&lt;p&gt;An &lt;strong&gt;idempotent&lt;/strong&gt; operation is one where doing it twice has the same effect as doing it once. &lt;code&gt;POST&lt;/code&gt; is not idempotent by default, but you can make it behave that way:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The client generates a unique &lt;strong&gt;idempotency key&lt;/strong&gt; for each &lt;em&gt;intent&lt;/em&gt; and sends it in a header.&lt;/li&gt;
&lt;li&gt;The server remembers the key together with the result.&lt;/li&gt;
&lt;li&gt;If the same key arrives again, the server returns the saved result and does not do the work again.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Payment providers such as Stripe use exactly this pattern with an &lt;code&gt;Idempotency-Key&lt;/code&gt; header.&lt;/p&gt;
&lt;h3 id=&quot;a-working-version-with-express-and-mongodb&quot;&gt;A Working Version With Express and MongoDB&lt;/h3&gt;
&lt;p&gt;First, a unique index, plus an expiry so old keys clean themselves up:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;await keys.createIndex({ userId: 1, key: 1 }, { unique: true });
await keys.createIndex({ createdAt: 1 }, { expireAfterSeconds: 86400 });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the endpoint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;app.post('/api/orders', requireAuth, async (req, res) =&amp;gt; {
  const key = req.get('Idempotency-Key');
  if (!key) return res.status(400).json({ error: 'Idempotency-Key header required' });

  const userId = req.user.id;
  const bodyHash = hash(stableStringify(req.body));

  // 1. Try to claim the key. Only one request can win this insert.
  try {
    await keys.insertOne({ userId, key, bodyHash, status: 'processing', createdAt: new Date() });
  } catch (err) {
    if (err.code !== 11000) throw err; // 11000 = duplicate key

    const saved = await keys.findOne({ userId, key });
    if (saved.bodyHash !== bodyHash) {
      return res.status(422).json({ error: 'Key was used with a different request' });
    }
    if (saved.status === 'processing') {
      return res.status(409).json({ error: 'Request is still being processed' });
    }
    return res.status(saved.statusCode).json(saved.response); // replay
  }

  // 2. We own the key. Do the work once.
  try {
    const order = await createOrder(userId, req.body);
    await chargeCard(order);

    const response = { orderId: order.id };
    await keys.updateOne(
      { userId, key },
      { $set: { status: 'done', statusCode: 201, response } }
    );
    return res.status(201).json(response);
  } catch (err) {
    await keys.deleteOne({ userId, key }); // let the client retry
    throw err;
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-race-condition-most-examples-miss&quot;&gt;The Race Condition Most Examples Miss&lt;/h3&gt;
&lt;p&gt;A common first attempt is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const existing = await keys.findOne({ userId, key });
if (existing) return res.json(existing.response);
// ... do the work ...
await keys.insertOne({ userId, key, response });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two simultaneous requests both run &lt;code&gt;findOne&lt;/code&gt;, both see nothing, and both do the work. You added a check and still got a duplicate.&lt;/p&gt;
&lt;p&gt;The fix is in the version above: &lt;strong&gt;claim the key first with an insert that can only succeed once&lt;/strong&gt;, and let the unique index decide the winner. The database settles the race, not your code.&lt;/p&gt;
&lt;h3 id=&quot;four-details-that-matter&quot;&gt;Four Details That Matter&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. Scope keys per user.&lt;/strong&gt; Two users who happen to generate the same key must not see each other's responses. That is why the index is on &lt;code&gt;{ userId, key }&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Compare the request body.&lt;/strong&gt; If the same key arrives with a different body, that is a bug or an attack, not a retry. Return an error instead of replaying a response that does not match the request. Use a stable stringify (sorted keys) for the hash, or two identical bodies with different property order will hash differently.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Generate the key per intent, not per attempt.&lt;/strong&gt; On the client, create the key when the user starts the action and &lt;strong&gt;reuse it for every retry&lt;/strong&gt; of that action. A new key per retry brings the duplicate right back.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const key = crypto.randomUUID(); // once, when the checkout starts
await fetch('/api/orders', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
  body: JSON.stringify(cart),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;4. Pass the key on to your payment provider too.&lt;/strong&gt; If your server crashes after charging the card but before saving the result, your own table cannot help. A key sent to the provider protects that gap, and the expiry index clears any stuck &lt;code&gt;processing&lt;/code&gt; record.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-1&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Any request that creates something or moves money must be safe to send twice.&lt;/strong&gt; Do not rely on disabled buttons or &quot;the network is reliable&quot;. Give the client a key, claim it atomically on the server, and replay the saved result on a repeat.&lt;/p&gt;
&lt;p&gt;Quick audit: list your &lt;code&gt;POST&lt;/code&gt; endpoints that create orders, payments, invoices, emails or bookings. For each, ask what happens if the request arrives twice, a second apart. If the answer is &quot;two of them&quot;, you have found your next ticket.&lt;/p&gt;
&lt;p&gt;If you work with MongoDB, my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com/blog/mongodb-mongoose-patterns-2026&quot;&gt;MongoDB and Mongoose patterns guide&lt;/a&gt; has more on using the database to enforce rules, and you can browse my Next.js &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;templates on pixelanas.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Have you ever shipped a double-charge or a duplicate-order bug? Tell me how you found out, in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32382/your-pay-now-button-can-charge-twice-idempotency-keys-are-the-fix-nobody-adds-until-happens</guid>
<pubDate>Sun, 25 Oct 2026 11:09:21 +0000</pubDate>
</item>
<item>
<title>forEach Doesn't Wait for Your await: The Bug That Makes APIs Reply Before the Work Is Don</title>
<link>https://coderlegion.com/31828/foreach-doesnt-wait-for-your-await-the-bug-that-makes-apis-reply-before-the-work-is-don</link>
<description>&lt;p&gt;Your endpoint sends 50 emails, marks 50 orders as shipped, and returns &lt;code&gt;{ success: true }&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;In your tests, it works. In production, the response comes back in 8 milliseconds, half the orders are still &lt;code&gt;pending&lt;/code&gt;, and one failed email crashed the whole Node process with an error nobody can trace to a request.&lt;/p&gt;
&lt;p&gt;The culprit is usually one innocent line.&lt;/p&gt;
&lt;h3 id=&quot;the-code-that-looks-fine&quot;&gt;The Code That Looks Fine&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;app.post('/api/ship', async (req, res) =&amp;gt; {
  const orders = await Order.find({ status: 'paid' });

  orders.forEach(async (order) =&amp;gt; {
    await sendShippingEmail(order);
    await Order.updateOne({ _id: order._id }, { status: 'shipped' });
  });

  res.json({ success: true });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is an &lt;code&gt;await&lt;/code&gt; inside, so it must wait. It does not.&lt;/p&gt;
&lt;h3 id=&quot;why-it-returns-early&quot;&gt;Why It Returns Early&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;forEach&lt;/code&gt; was written before promises existed. It calls your function for each item and &lt;strong&gt;ignores whatever the function returns&lt;/strong&gt;. An &lt;code&gt;async&lt;/code&gt; function returns a promise, and &lt;code&gt;forEach&lt;/code&gt; throws that promise away without looking at it.&lt;/p&gt;
&lt;p&gt;So the loop starts 50 async tasks, finishes in one tick, and moves on. The &lt;code&gt;await&lt;/code&gt; inside each callback only pauses that callback, not the loop around it, and not your handler.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;console.log('start');
[1, 2, 3].forEach(async (n) =&amp;gt; {
  await new Promise((r) =&amp;gt; setTimeout(r, 100));
  console.log('done', n);
});
console.log('end');

// start
// end
// done 1
// done 2
// done 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;end&lt;/code&gt; prints before any work finishes. That is your &lt;code&gt;res.json()&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;why-it-is-hard-to-catch&quot;&gt;Why It Is Hard to Catch&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fast tests hide it.&lt;/strong&gt; With a local database and a mocked email service, the work finishes before your next assertion runs. It passes by luck.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The failure is somewhere else.&lt;/strong&gt; The response says success, so nothing points back to this line.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Errors escape.&lt;/strong&gt; If a callback throws, nobody is awaiting that promise, so there is no &lt;code&gt;try/catch&lt;/code&gt; that can catch it. Since Node 15, an unhandled rejection crashes the process by default. One bad email address can take down the server, and the stack trace will not mention the request that caused it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;fix-1-need-it-sequential-use-for-of&quot;&gt;Fix 1: Need It Sequential? Use for...of&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;for (const order of orders) {
  await sendShippingEmail(order);
  await Order.updateOne({ _id: order._id }, { status: 'shipped' });
}

res.json({ success: true });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;for...of&lt;/code&gt; is real control flow, so &lt;code&gt;await&lt;/code&gt; pauses the whole loop. Errors land in your &lt;code&gt;try/catch&lt;/code&gt;. Use this when order matters, or when you must not hit a rate-limited API in parallel.&lt;/p&gt;
&lt;h3 id=&quot;fix-2-need-it-fast-promise-all-with-map&quot;&gt;Fix 2: Need It Fast? Promise.all With map&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;await Promise.all(
  orders.map(async (order) =&amp;gt; {
    await sendShippingEmail(order);
    await Order.updateOne({ _id: order._id }, { status: 'shipped' });
  })
);

res.json({ success: true });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;map&lt;/code&gt; returns the array of promises that &lt;code&gt;forEach&lt;/code&gt; was throwing away, and &lt;code&gt;Promise.all&lt;/code&gt; waits for all of them. Same callback, one different method.&lt;/p&gt;
&lt;h3 id=&quot;fix-3-some-failures-are-acceptable-use-allsettled&quot;&gt;Fix 3: Some Failures Are Acceptable? Use allSettled&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Promise.all&lt;/code&gt; rejects as soon as one promise fails, but &lt;strong&gt;the other tasks keep running&lt;/strong&gt;. You get an error back while half the work still completes in the background.&lt;/p&gt;
&lt;p&gt;If you want every task to finish and then look at what failed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const results = await Promise.allSettled(orders.map(shipOrder));

const failed = results
  .map((r, i) =&amp;gt; ({ r, order: orders[i] }))
  .filter(({ r }) =&amp;gt; r.status === 'rejected');

res.json({
  shipped: results.length - failed.length,
  failed: failed.map(({ order }) =&amp;gt; order._id),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now one bad email does not hide the 49 that worked, and the response tells the caller exactly what to retry.&lt;/p&gt;
&lt;h3 id=&quot;fix-4-thousands-of-items-limit-the-concurrency&quot;&gt;Fix 4: Thousands of Items? Limit the Concurrency&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Promise.all(items.map(...))&lt;/code&gt; starts everything at once. With 10,000 items, that is 10,000 simultaneous database calls or HTTP requests, which can exhaust your connection pool or get you rate limited.&lt;/p&gt;
&lt;p&gt;Process in batches:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function inBatches(items, size, fn) {
  const results = [];
  for (let i = 0; i &amp;lt; items.length; i += size) {
    const batch = items.slice(i, i + size);
    results.push(...(await Promise.all(batch.map(fn))));
  }
  return results;
}

await inBatches(orders, 10, shipOrder);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ten at a time is fast enough for most jobs and gentle on whatever is on the other end.&lt;/p&gt;
&lt;h3 id=&quot;same-trap-in-filter-some-every-and-reduce&quot;&gt;Same Trap in filter, some, every and reduce&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;forEach&lt;/code&gt; is not the only method that does not understand promises.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// Always keeps everything. A Promise is truthy.
const valid = users.filter(async (u) =&amp;gt; await isActive(u));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The callback returns a promise for every user, and a promise object is always truthy, so nothing is ever filtered out. &lt;code&gt;some&lt;/code&gt; and &lt;code&gt;every&lt;/code&gt; have the same problem and return &lt;code&gt;true&lt;/code&gt; immediately.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// Resolve first, then filter
const flags = await Promise.all(users.map(isActive));
const valid = users.filter((_, i) =&amp;gt; flags[i]);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-rule-2&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Array methods like &lt;code&gt;forEach&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;some&lt;/code&gt; and &lt;code&gt;every&lt;/code&gt; do not await. If an &lt;code&gt;async&lt;/code&gt; function sits inside one, the work is detached from your request.&lt;/strong&gt; Use &lt;code&gt;for...of&lt;/code&gt; to run steps in order, and &lt;code&gt;Promise.all&lt;/code&gt; or &lt;code&gt;Promise.allSettled&lt;/code&gt; with &lt;code&gt;map&lt;/code&gt; to run them together.&lt;/p&gt;
&lt;p&gt;Quick audit: search your project for &lt;code&gt;.forEach(async&lt;/code&gt;. In a route handler, a job or a script that exits when it finishes, each hit is either a bug or a race that has not lost yet.&lt;/p&gt;
&lt;p&gt;I use this pattern in the bulk operations behind my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com/blog/mongodb-mongoose-patterns-2026&quot;&gt;Next.js and MongoDB templates on pixelanas.com&lt;/a&gt;, where seeding, imports and batch updates must finish before the response goes out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;How many &lt;code&gt;.forEach(async&lt;/code&gt; do you find in your codebase? Drop the number in the comments, and tell me if one of them turned out to be a real bug.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31828/foreach-doesnt-wait-for-your-await-the-bug-that-makes-apis-reply-before-the-work-is-don</guid>
<pubDate>Tue, 20 Oct 2026 10:16:43 +0000</pubDate>
</item>
<item>
<title>I built a Supabase-style query client that talks directly to Postgres</title>
<link>https://coderlegion.com/31116/i-built-a-supabase-style-query-client-that-talks-directly-to-postgres</link>
<description>&lt;p&gt;I like the Supabase JS client. &lt;code&gt;.from('todos').select('id, task').eq('done', false)&lt;/code&gt; reads like what I mean, and &lt;code&gt;{ data, error }&lt;/code&gt; means I'm not wrapping every query in try/catch.&lt;/p&gt;
&lt;p&gt;What I don't always want is the stuff around it: a project URL, an anon key, and a PostgREST service sitting between my Node server and a Postgres I already control.&lt;/p&gt;
&lt;p&gt;So I built &lt;strong&gt;pg-fluent&lt;/strong&gt;: the &lt;code&gt;postgrest-js&lt;/code&gt; query builder, executed directly against PostgreSQL through &lt;code&gt;node-postgres&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { createClient } from 'pg-fluent'

const db = createClient({ connectionString: process.env.DATABASE_URL })

const { data, error } = await db
  .from('todos')
  .select('id, task, done')
  .eq('done', false)
  .order('id')
  .limit(10)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you've used &lt;code&gt;supabase-js&lt;/code&gt;, that's already familiar. The difference is there's no HTTP request on the other end, just one parameterized SQL statement over a normal Postgres connection.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install pg-fluent
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Repo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/gerald-mathew/pg-fluent&quot;&gt;https://github.com/gerald-mathew/pg-fluent&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;who-it-s-for&quot;&gt;Who it's for&lt;/h3&gt;
&lt;p&gt;People running long-lived servers (Express, Fastify, a VPS, containers) who like the Supabase query API but want to connect straight to Postgres, or who are moving off Supabase's hosted stack and don't want to rewrite every query. It's &lt;strong&gt;server-side only&lt;/strong&gt;, more on that below.&lt;/p&gt;
&lt;h3 id=&quot;how-it-works&quot;&gt;How it works&lt;/h3&gt;
&lt;p&gt;I didn't reimplement the builder. pg-fluent uses the real &lt;code&gt;@supabase/postgrest-js&lt;/code&gt; and hands it a custom &lt;code&gt;fetch&lt;/code&gt;. When the builder &quot;sends&quot; a request, pg-fluent catches it, parses the URL, headers and body the way PostgREST would, plans one parameterized SQL statement, runs it through &lt;code&gt;pg&lt;/code&gt;, and returns a response &lt;code&gt;postgrest-js&lt;/code&gt; unpacks as if a server had answered.&lt;/p&gt;
&lt;p&gt;Results are aggregated to JSON inside Postgres, so you get the same shape as Supabase. Embeds like &lt;code&gt;select('id, title, author:users(name)')&lt;/code&gt; become subqueries that aggregate to JSON, with relationships resolved from the live schema (cached, with auto-refresh).&lt;/p&gt;
&lt;p&gt;Since I'm intercepting what the builder emits, an upstream change could quietly change the SQL. So the dependency is pinned to an exact version, and &lt;code&gt;docs/UPSTREAM.md&lt;/code&gt; records every bump with the date and reason. A bump that changes observable SQL or results is at least a minor release.&lt;/p&gt;
&lt;h3 id=&quot;what-you-get-beyond-the-basics&quot;&gt;What you get beyond the basics&lt;/h3&gt;
&lt;p&gt;The whole &lt;code&gt;postgrest-js&lt;/code&gt; surface works: filters, &lt;code&gt;or()&lt;/code&gt;/&lt;code&gt;not()&lt;/code&gt;/&lt;code&gt;filter()&lt;/code&gt;, JSON paths like &lt;code&gt;meta-&amp;gt;&amp;gt;author&lt;/code&gt;, &lt;code&gt;range()&lt;/code&gt;, &lt;code&gt;single()&lt;/code&gt;/&lt;code&gt;maybeSingle()&lt;/code&gt;, &lt;code&gt;count&lt;/code&gt;, &lt;code&gt;upsert&lt;/code&gt;, &lt;code&gt;rpc&lt;/code&gt;, &lt;code&gt;csv()&lt;/code&gt;, &lt;code&gt;explain()&lt;/code&gt;, and embeds (many-to-one, one-to-many, many-to-many, &lt;code&gt;!inner&lt;/code&gt;, aliases, spread). Then the things that only make sense with a direct connection:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;See the SQL without running it:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const { text, values } = await db.from('todos').select('id').eq('done', false).toSQL()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Transactions with savepoints:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const user = await db.transaction(async (trx) =&amp;gt; {
  const { data, error } = await trx.from('users').insert({ name: 'G' }).select().single()
  if (error) throw error
  await trx.from('profiles').insert({ user_id: data.id })
  return data
}, { isolationLevel: 'read committed' })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Raw SQL, safe by default.&lt;/strong&gt; The tagged template always binds interpolations as parameters. The only way to run SQL text you built yourself is &lt;code&gt;db.unsafe()&lt;/code&gt;, and the name is deliberate.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const { data } = await db.sql`select * from users where id = ${id}`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Streaming, COPY, LISTEN/NOTIFY:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;for await (const row of db.from('events').select('*').stream()) {
  await handle(row)
}

await db.copyFrom('events', rows, { columns: ['id', 'kind'], format: 'csv' })

const sub = await db.listen('orders', (payload) =&amp;gt; console.log(payload))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The listener gets its own non-pooled connection and reconnects with backoff.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Named connections and read replicas.&lt;/strong&gt; Reads can go to replicas, while writes, RPC, transactions and LISTEN always hit the primary. Add &lt;code&gt;.primary()&lt;/code&gt; when you need read-your-writes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Type generation:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx pg-fluent gen-types --connection &quot;$DATABASE_URL&quot; --schema public --out ./database.types.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The output is a &lt;code&gt;Database&lt;/code&gt; type compatible with &lt;code&gt;postgrest-js&lt;/code&gt;, so &lt;code&gt;createClient&amp;lt;Database&amp;gt;(...)&lt;/code&gt; just works.&lt;/p&gt;
&lt;p&gt;There are also &lt;code&gt;logger&lt;/code&gt; and &lt;code&gt;onQuery&lt;/code&gt; hooks and optional OpenTelemetry spans if &lt;code&gt;@opentelemetry/api&lt;/code&gt; is installed. Parameter values are never logged unless you opt in.&lt;/p&gt;
&lt;h3 id=&quot;guardrails-by-default&quot;&gt;Guardrails by default&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unfiltered &lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; is refused&lt;/strong&gt; with &lt;code&gt;PGFLUENT_UNFILTERED_MUTATION&lt;/code&gt;. Opt in with &lt;code&gt;.allowUnfiltered()&lt;/code&gt; or a client option.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reads are capped:&lt;/strong&gt; &lt;code&gt;maxRows&lt;/code&gt; defaults to 1000 when you set no limit or range.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Statements time out:&lt;/strong&gt; &lt;code&gt;statementTimeoutMs&lt;/code&gt; defaults to 30 seconds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retries are read-only.&lt;/strong&gt; Transient connection failures retry with backoff for reads, and writes are never retried.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;how-i-tested-it&quot;&gt;How I tested it&lt;/h3&gt;
&lt;p&gt;I didn't want to say &quot;drop-in&quot; on a hunch, so:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Differential tests against a real PostgREST server.&lt;/strong&gt; The same requests go through both, and the payloads must match. One difference is intentional and documented: a reserved parameter name like &lt;code&gt;columns&lt;/code&gt; or &lt;code&gt;select&lt;/code&gt; used as a filter is rejected with &lt;code&gt;PGFLUENT_INVALID_QUERY&lt;/code&gt;. PostgREST silently ignores it, which turns a scoped read into a full-table read.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Property-based injection tests&lt;/strong&gt; with &lt;code&gt;fast-check&lt;/code&gt;. Hostile strings go in as filter values, the SQL text must match the baseline, the values must land in parameters only, and a canary table must survive.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A real CI matrix:&lt;/strong&gt; Node 22 and 24 against Postgres 15, 16, 17 and 18.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That's 200+ tests. There's also a benchmark harness against raw &lt;code&gt;pg&lt;/code&gt;, but I'm &lt;strong&gt;not&lt;/strong&gt; publishing performance claims. Network and pooling change the numbers too much, so it only reports methodology and variance for you to run yourself.&lt;/p&gt;
&lt;h3 id=&quot;the-rls-caveat-please-read-&quot;&gt;The RLS caveat (please read)&lt;/h3&gt;
&lt;p&gt;pg-fluent runs statements as &lt;strong&gt;the connection's own role&lt;/strong&gt;, like &lt;code&gt;psql&lt;/code&gt; or any other driver. It doesn't set &lt;code&gt;request.jwt.claims&lt;/code&gt; and doesn't emulate Supabase's &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;authenticated&lt;/code&gt; roles.&lt;/p&gt;
&lt;p&gt;So &lt;strong&gt;RLS policies written for Supabase's roles don't automatically protect you here&lt;/strong&gt;. Connect as the table owner or a &lt;code&gt;BYPASSRLS&lt;/code&gt; role and RLS is skipped entirely. Use a least-privilege role, keep this on the server, and never let database credentials reach a browser. For per-user enforcement, connect as a non-owner role and set &lt;code&gt;SET ROLE&lt;/code&gt; and the JWT claims yourself. If you're migrating from &lt;code&gt;supabase-js&lt;/code&gt;, treat this as step zero.&lt;/p&gt;
&lt;h3 id=&quot;what-s-missing-or-rough&quot;&gt;What's missing or rough&lt;/h3&gt;
&lt;p&gt;It's &lt;code&gt;0.2.x&lt;/code&gt;, so, honestly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Embedding &lt;strong&gt;views&lt;/strong&gt; isn't supported yet (you can read them, just not embed them).&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;auth&lt;/code&gt;, &lt;code&gt;storage&lt;/code&gt; or &lt;code&gt;functions&lt;/code&gt;. It's a database client, not a BaaS SDK.&lt;/li&gt;
&lt;li&gt;LISTEN/NOTIFY doesn't work through transaction-mode poolers like PgBouncer. Use session-mode or a direct connection.&lt;/li&gt;
&lt;li&gt;Replica reads are asynchronous, and there's no lag measurement in v1.&lt;/li&gt;
&lt;li&gt;Requires Node 22+ and Postgres 15 to 18.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;try-it-break-it&quot;&gt;Try it, break it&lt;/h3&gt;
&lt;p&gt;If you have a Supabase-style codebase and a Postgres you control, point &lt;code&gt;pg-fluent&lt;/code&gt; at a test database and tell me what breaks. Issues and PRs are welcome at &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/gerald-mathew/pg-fluent&quot;&gt;https://github.com/gerald-mathew/pg-fluent&lt;/a&gt;, and security reports go through the private channel in &lt;code&gt;SECURITY.md&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I especially want to know which &lt;code&gt;postgrest-js&lt;/code&gt; queries behave differently from what you expect. Real queries find things my fixtures won't.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31116/i-built-a-supabase-style-query-client-that-talks-directly-to-postgres</guid>
<pubDate>Wed, 14 Oct 2026 22:57:08 +0000</pubDate>
</item>
<item>
<title>Retry Failed n8n API Requests Without Duplicates</title>
<link>https://coderlegion.com/25675/retry-failed-n8n-api-requests-without-duplicates</link>
<description>
&lt;p&gt;
        The safe n8n retry pattern is simple: create one stable idempotency key for the business operation, send the same key and payload on every attempt, retry only temporary failures, and store the confirmed result. Turning on &lt;strong&gt;Retry On Fail&lt;/strong&gt; without that protection can repeat a successful side effect when the first response was lost.
      &lt;/p&gt;

      &lt;div class=&quot;article-callout&quot;&gt;
        &lt;p&gt;&lt;strong&gt;Partner disclosure.&lt;/strong&gt; This guide includes a partner link to &lt;a href=&quot;https://n8n.partnerlinks.io/6mg2xng7s1m6&quot; target=&quot;_blank&quot; rel=&quot;sponsored noopener nofollow&quot;&gt;n8n Cloud&lt;/a&gt;. If you sign up through it, I may earn a commission at no extra cost to you.&lt;/p&gt;
      &lt;/div&gt;

      &lt;div class=&quot;article-callout&quot;&gt;
        &lt;p&gt;&lt;strong&gt;The dangerous case:&lt;/strong&gt; an API creates the order, but the connection closes before n8n receives the response. n8n sees a timeout. A second POST with a new key can create a second order even though the first request actually worked.&lt;/p&gt;
      &lt;/div&gt;

      &lt;h2 id=&quot;decide-whether-the-request-is-safe-to-repeat&quot;&gt;Decide whether the request is safe to repeat&lt;/h2&gt;
      &lt;p&gt;
        HTTP defines GET, HEAD, OPTIONS, and TRACE as safe methods. PUT, DELETE, and the safe methods are idempotent by their intended semantics. POST is not automatically idempotent. The HTTP specification therefore says a client should not automatically retry a non-idempotent request unless it knows the operation is idempotent or can prove the first request was not applied.
      &lt;/p&gt;
      &lt;p&gt;
        Real APIs add their own rules, so the provider's documentation remains the contract. A DELETE endpoint may be harmless to repeat, while a POST that sends an email, creates a payment, or reserves stock needs explicit duplicate protection.
      &lt;/p&gt;

      &lt;div class=&quot;article-table-wrap&quot;&gt;
        &lt;table class=&quot;article-table&quot;&gt;
          &lt;thead&gt;
            &lt;tr&gt;
              &lt;th&gt;Failure&lt;/th&gt;
              &lt;th&gt;Default action&lt;/th&gt;
              &lt;th&gt;Duplicate risk&lt;/th&gt;
            &lt;/tr&gt;
          &lt;/thead&gt;
          &lt;tbody&gt;
            &lt;tr&gt;
              &lt;td&gt;Network timeout or reset&lt;/td&gt;
              &lt;td&gt;Retry the identical request with the same operation key&lt;/td&gt;
              &lt;td&gt;High, because the remote service may already have committed the action&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;429 Too Many Requests&lt;/td&gt;
              &lt;td&gt;Wait for the provider's limit window, then retry&lt;/td&gt;
              &lt;td&gt;Controlled only if the operation remains idempotent&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;500, 502, 503, or 504&lt;/td&gt;
              &lt;td&gt;Use a small bounded retry policy when the provider permits it&lt;/td&gt;
              &lt;td&gt;Potentially high for state-changing requests&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;400, 401, 403, or most 404s&lt;/td&gt;
              &lt;td&gt;Fix the request, credentials, permission, or resource first&lt;/td&gt;
              &lt;td&gt;Blind retries add load but rarely fix the cause&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;409 Conflict&lt;/td&gt;
              &lt;td&gt;Inspect the API-specific error before deciding&lt;/td&gt;
              &lt;td&gt;May indicate an existing operation or key conflict&lt;/td&gt;
            &lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;

      &lt;h2 id=&quot;step-1-create-one-key-for-one-business-operation&quot;&gt;Step 1: create one key for one business operation&lt;/h2&gt;
      &lt;p&gt;
        Build the key before the HTTP Request node from an ID that already belongs to the event: an order ID, checkout ID, lead ID, invoice ID, or source event ID. Include the action name so different operations on the same record cannot collide.
      &lt;/p&gt;
      &lt;pre class=&quot;article-code&quot;&gt;={{ 'create-order:' + $json.orderId }}&lt;/pre&gt;
      &lt;p&gt;
        Keep this value unchanged across automatic node retries, manual execution retries, and later recovery runs. Do not use &lt;code&gt;$now&lt;/code&gt;, the execution ID, or a freshly generated random value on each attempt. Those identify the attempt, not the operation, so the destination sees every retry as new work.
      &lt;/p&gt;
      &lt;p&gt;
        The key must also stay paired with the same endpoint and payload. If you correct the amount, recipient, or other business data, treat that as a deliberate new operation according to the provider's rules.
      &lt;/p&gt;

      &lt;h2 id=&quot;step-2-use-the-api-s-idempotency-feature-first&quot;&gt;Step 2: use the API's idempotency feature first&lt;/h2&gt;
      &lt;p&gt;
        If the destination API supports idempotency, send the stable key in the exact header or field its documentation specifies. Stripe, for example, accepts an &lt;code&gt;Idempotency-Key&lt;/code&gt; header for POST requests and returns the stored result when the same request is repeated with the same key. That header name is not universal. Some APIs use a request ID, client reference, deduplication ID, or a provider-specific field.
      &lt;/p&gt;
      &lt;ol&gt;
        &lt;li&gt;Open the HTTP Request node and add the provider's documented idempotency header.&lt;/li&gt;
        &lt;li&gt;Map the stable operation key as its value.&lt;/li&gt;
        &lt;li&gt;Keep the method, URL, and request body identical on every retry.&lt;/li&gt;
        &lt;li&gt;Save the external object ID from the confirmed response for later reconciliation.&lt;/li&gt;
      &lt;/ol&gt;
      &lt;div class=&quot;article-callout&quot;&gt;
        &lt;p&gt;&lt;strong&gt;Do not assume:&lt;/strong&gt; adding an &lt;code&gt;Idempotency-Key&lt;/code&gt; header only works when the destination implements that contract. An unknown header may simply be ignored.&lt;/p&gt;
      &lt;/div&gt;

      &lt;h2 id=&quot;step-3-configure-a-bounded-n8n-retry&quot;&gt;Step 3: configure a bounded n8n retry&lt;/h2&gt;
      &lt;p&gt;
        Every n8n node has a &lt;strong&gt;Retry On Fail&lt;/strong&gt; setting. When enabled, n8n reruns the node after a failure until it succeeds or reaches the configured attempt limit. For a known transient endpoint, start with a small policy such as three tries and a short wait between tries. Increase it only when the provider's limit and incident behavior justify the extra traffic.
      &lt;/p&gt;
      &lt;p&gt;
        This built-in setting is best when every error produced by that node is safe to retry. If you need status-aware behavior, enable &lt;strong&gt;Include Response Headers and Status&lt;/strong&gt; and &lt;strong&gt;Never Error&lt;/strong&gt; in the HTTP Request node. The node can then pass the status code and headers to an IF or Switch branch instead of stopping on every non-2xx response.
      &lt;/p&gt;
      &lt;ul&gt;
        &lt;li&gt;Send 2xx responses to the success branch.&lt;/li&gt;
        &lt;li&gt;Send 429 and approved temporary 5xx responses to a Wait step and bounded retry path.&lt;/li&gt;
        &lt;li&gt;Send authentication, validation, permission, and unexpected responses to a review or alert branch.&lt;/li&gt;
        &lt;li&gt;Stop after the maximum attempt count. Never create an unbounded loop.&lt;/li&gt;
      &lt;/ul&gt;

      &lt;h2 id=&quot;step-4-slow-down-429-retries&quot;&gt;Step 4: slow down 429 retries&lt;/h2&gt;
      &lt;p&gt;
        A 429 response means the client sent too many requests in the provider's current limit window. Some APIs include &lt;code&gt;Retry-After&lt;/code&gt; as seconds or an HTTP date. Read and honor it when the provider documents that behavior. If the header is absent, use the API's published limit and increase the delay between attempts rather than retrying immediately.
      &lt;/p&gt;
      &lt;p&gt;
        The HTTP Request node also has &lt;strong&gt;Items per Batch&lt;/strong&gt; and &lt;strong&gt;Batch Interval&lt;/strong&gt; options. They control how many input items are sent in each batch and how many milliseconds n8n waits between batches. Use them to prevent a known burst from creating the 429 in the first place.
      &lt;/p&gt;

      &lt;h2 id=&quot;step-5-add-a-persistent-duplicate-check-when-needed&quot;&gt;Step 5: add a persistent duplicate check when needed&lt;/h2&gt;
      &lt;p&gt;
        When the API has no idempotency feature, keep a small request ledger. n8n's Data Table node can check whether a row exists, insert rows, update them, and upsert by conditions. A practical table uses these columns:
      &lt;/p&gt;
      &lt;div class=&quot;article-table-wrap&quot;&gt;
        &lt;table class=&quot;article-table&quot;&gt;
          &lt;thead&gt;
            &lt;tr&gt;
              &lt;th&gt;Column&lt;/th&gt;
              &lt;th&gt;Purpose&lt;/th&gt;
            &lt;/tr&gt;
          &lt;/thead&gt;
          &lt;tbody&gt;
            &lt;tr&gt;
              &lt;td&gt;&lt;code&gt;request_key&lt;/code&gt;&lt;/td&gt;
              &lt;td&gt;Stable ID for the business operation&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;&lt;code&gt;status&lt;/code&gt;&lt;/td&gt;
              &lt;td&gt;&lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;succeeded&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt;, or &lt;code&gt;unknown&lt;/code&gt;&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;&lt;code&gt;external_id&lt;/code&gt;&lt;/td&gt;
              &lt;td&gt;ID returned by the destination when available&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;&lt;code&gt;updated_at&lt;/code&gt;&lt;/td&gt;
              &lt;td&gt;Timestamp for recovery and stale-pending review&lt;/td&gt;
            &lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;
      &lt;ol&gt;
        &lt;li&gt;Check for a row with the request key.&lt;/li&gt;
        &lt;li&gt;If it already says &lt;code&gt;succeeded&lt;/code&gt;, return the stored result and skip the API call.&lt;/li&gt;
        &lt;li&gt;If no row exists, record &lt;code&gt;pending&lt;/code&gt;, then call the API.&lt;/li&gt;
        &lt;li&gt;On a confirmed 2xx response, update the row to &lt;code&gt;succeeded&lt;/code&gt; and store the external ID.&lt;/li&gt;
        &lt;li&gt;On a timeout, keep the state as &lt;code&gt;unknown&lt;/code&gt; until you query the destination or retry safely.&lt;/li&gt;
      &lt;/ol&gt;
      &lt;p&gt;
        This ledger is useful for sequential or low-concurrency workflows, but a check followed by an insert is not an atomic lock. Two executions can check at the same moment and both see no row. For parallel production traffic, prefer destination-side idempotency or a database table with a unique constraint on &lt;code&gt;request_key&lt;/code&gt; and an atomic claim operation.
      &lt;/p&gt;
      &lt;p&gt;
        That pattern protects one API operation from replay. When the business problem is recognizing the same person across forms, email, phone, and an existing CRM, use the separate &lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/n8n-deduplicate-leads-before-crm&quot;&gt;n8n lead deduplication workflow&lt;/a&gt; and its conflict-review path.
      &lt;/p&gt;

      &lt;h2 id=&quot;do-not-use-workflow-static-data-as-a-production-lock&quot;&gt;Do not use workflow static data as a production lock&lt;/h2&gt;
      &lt;p&gt;
        The &lt;code&gt;$getWorkflowStaticData()&lt;/code&gt; helper looks convenient for storing processed IDs, but n8n labels it experimental, says the data should be small, and warns that it may behave unreliably under high-frequency executions. That makes it unsuitable as the main duplicate barrier for valuable orders, payments, leads, or outbound messages.
      &lt;/p&gt;

      &lt;h2 id=&quot;a-production-safe-request-sequence&quot;&gt;A production-safe request sequence&lt;/h2&gt;
      &lt;ol&gt;
        &lt;li&gt;Validate that the event contains a stable source ID.&lt;/li&gt;
        &lt;li&gt;Create the operation key once.&lt;/li&gt;
        &lt;li&gt;Check any local success record.&lt;/li&gt;
        &lt;li&gt;Send the request with the provider's idempotency mechanism.&lt;/li&gt;
        &lt;li&gt;Retry only network failures, 429s, and provider-approved temporary errors.&lt;/li&gt;
        &lt;li&gt;Reuse the same key and payload for every attempt.&lt;/li&gt;
        &lt;li&gt;Store the external ID after confirmed success.&lt;/li&gt;
        &lt;li&gt;Route exhausted or uncertain outcomes to an Error Workflow or manual review queue.&lt;/li&gt;
      &lt;/ol&gt;
      &lt;p&gt;
        Keep request-level recovery separate from broad diagnosis. If you do not yet know why the node failed, use the &lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/n8n-debug-failed-workflows&quot;&gt;failed n8n workflow debugging guide&lt;/a&gt;. Once the retry budget is exhausted, use an &lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/n8n-error-workflow-telegram-alerts&quot;&gt;n8n Error Workflow&lt;/a&gt; to send the failure to Telegram, Slack, Google Chat, email, or another incident channel.
      &lt;/p&gt;

      &lt;h2 id=&quot;common-questions&quot;&gt;Common questions&lt;/h2&gt;
      &lt;h3&gt;Does Retry On Fail prevent duplicates in n8n?&lt;/h3&gt;
      &lt;p&gt;
        No. It repeats the failed node. Duplicate prevention comes from idempotent request semantics, a provider-supported operation key, or a persistent store that can claim each business operation exactly once.
      &lt;/p&gt;

      &lt;h3&gt;Should I retry every 500 response?&lt;/h3&gt;
      &lt;p&gt;
        Not automatically. A 500 can leave a state-changing operation with an uncertain outcome. Follow the provider's error guidance, keep the same idempotency key, use a bounded retry policy, and reconcile the remote record when the result remains unknown.
      &lt;/p&gt;

      &lt;h3&gt;Can the Remove Duplicates node replace an idempotency key?&lt;/h3&gt;
      &lt;p&gt;
        It can remove repeated input items, but it cannot prove that a remote API did or did not apply an earlier request after a timeout. Protect the side effect at the API or persistence layer.
      &lt;/p&gt;

      &lt;h2 id=&quot;sources-checked&quot;&gt;Sources checked&lt;/h2&gt;
      &lt;ul&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/workflows/components/nodes/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n node settings and Retry On Fail&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n HTTP Request options, batching, response handling, and timeout&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.datatable/rows/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n Data Table row operations&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/code/cookbook/builtin/get-workflow-static-data/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n workflow static data limitations&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9110.html#name-idempotent-methods&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;RFC 9110 idempotent method semantics&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.stripe.com/api/idempotent_requests&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;Stripe idempotent request behavior&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;Retry-After response header reference&lt;/a&gt;&lt;/li&gt;
      &lt;/ul&gt;

      &lt;div class=&quot;article-referral-card&quot;&gt;
        &lt;div&gt;
          &lt;p class=&quot;article-referral-kicker mono&quot;&gt;n8n partner link&lt;/p&gt;
          &lt;p class=&quot;cta-h&quot;&gt;Want managed n8n for the workflow?&lt;/p&gt;
          &lt;p&gt;I may earn a commission if you sign up through this link, at no extra cost to you.&lt;/p&gt;
        &lt;/div&gt;
        &lt;a href=&quot;https://n8n.partnerlinks.io/6mg2xng7s1m6&quot; target=&quot;_blank&quot; rel=&quot;sponsored noopener nofollow&quot; class=&quot;btn btn-primary article-referral-button&quot;&gt;Try n8n Cloud&lt;/a&gt;
      &lt;/div&gt;

      &lt;nav class=&quot;article-related&quot; aria-label=&quot;Related reading&quot;&gt;
        &lt;p class=&quot;article-related-h mono&quot;&gt;Keep reading&lt;/p&gt;
        &lt;ul&gt;
          &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/n8n-debug-failed-workflows&quot;&gt;How to Debug Failed n8n Workflows&lt;/a&gt;&lt;/li&gt;
          &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/n8n-error-workflow-telegram-alerts&quot;&gt;Protect n8n Workflows with Error Alerts&lt;/a&gt;&lt;/li&gt;
          &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/n8n-webhook-not-working&quot;&gt;n8n Webhook Not Working: Test URL, Production URL, and 404 Fixes&lt;/a&gt;&lt;/li&gt;
          &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/cases/website-uptime-monitoring-n8n&quot;&gt;Website Uptime Monitoring with n8n&lt;/a&gt;&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/nav&gt;

      &lt;div class=&quot;article-cta&quot;&gt;
        &lt;p class=&quot;cta-h&quot;&gt;Need one risky API workflow mapped?&lt;/p&gt;
        &lt;p&gt;Send the API operation, failure response, source ID, and expected business outcome. I will help separate safe retries from duplicate risk.&lt;/p&gt;
        &lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/#contact&quot; class=&quot;btn btn-primary article-cta-button&quot;&gt;Map the retry path&lt;/a&gt;
      &lt;/div&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/n8n-retry-failed-api-requests-without-duplicates&quot;&gt;FloxoLab&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/25675/retry-failed-n8n-api-requests-without-duplicates</guid>
<pubDate>Wed, 14 Oct 2026 15:50:00 +0000</pubDate>
</item>
<item>
<title>Why tests should verify runtime contracts, not inferred types</title>
<link>https://coderlegion.com/30509/why-tests-should-verify-runtime-contracts-not-inferred-types</link>
<description>&lt;p&gt;If your Node tests are green but callers still need casts, you’re mixing two different guarantees. A Node test proves what the function did when executed: the values returned, whether the input was mutated, and which inputs throw. It does not and cannot prove how the TypeScript compiler will narrow that result at a later use site.&lt;/p&gt;
&lt;p&gt;Implementation summary&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keep the runtime contract explicit: the helper should validate inputs (e.g., reject non-arrays) and return a new array that removes only null and undefined (use a nullish check rather than truthiness). The server-inserted example shows a small function that performs exactly that behavior and does not mutate the input.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Expected behavior to test&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Mixed-array case: the output contains the original non-nullish items in order and preserves falsy-but-valid values like 0, '', and false.&lt;/li&gt;
&lt;li&gt;Empty input: returns a new empty array (not the same reference).&lt;/li&gt;
&lt;li&gt;All-nullish input: returns an empty array.&lt;/li&gt;
&lt;li&gt;Invalid input: throws a TypeError for non-array arguments.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Where static checking belongs&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If you need the helper to provide compiler-level narrowing (so consumers can assign without casts), verify that with the TypeScript compiler or editor diagnostics. A minimal compile-time check is to write a small use-site that only accepts the narrowed type and run tsc to ensure it typechecks.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Limitations and pitfalls&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Compiler inference changes across TypeScript versions (e.g., TypeScript 5.5 adds inferred predicates in some filter callbacks). Relying on runtime tests to encode compiler behavior makes tests brittle or misleading.&lt;/li&gt;
&lt;li&gt;Truthiness-based filters (e.g., !!x) can silently drop legitimate falsy values — runtime tests must check exact outputs, not just lengths.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Practical rule&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tests: prove the runtime contract you actually depend on. Compiler: prove the static narrowing your consumers rely on. Keep them separate so failures point to the right fix: change JS behavior, adjust types, or update consumers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The article includes a repaired example and concrete test cases that illustrate this split and avoid overclaiming what a JavaScript test can prove. The server will append the full code example and the article URL.&lt;/p&gt;
&lt;h3 id=&quot;working-example&quot;&gt;Working example&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export const note = `This module demonstrates runtime removal of null and undefined from arrays.
Runtime tests (node:test) validate behavior at execution time but do NOT prove any TypeScript compile-time narrowing. To check static narrowing you must run the TypeScript compiler (tsc) or use editor/IDE type checks.`;

/**
 * Remove null and undefined from an array while preserving order and other falsy
 * values such as 0, '', and false.
 *
 * Runtime contract: returns a new array containing only elements that are not
 * null and not undefined. Throws a TypeError if the argument is not an Array.
 *
 * Note: This demonstrates runtime behavior only. TypeScript's ability to
 * narrow types (e.g. from (T | null | undefined)[] to T[]) is a compile-time
 * property checked by tsc / the language service and is not proven by these
 * runtime tests.
 */
export function filterNonNullish(arr) {
  if (!Array.isArray(arr)) {
    throw new TypeError('filterNonNullish expects an Array');
  }

  return arr.filter((x) =&amp;gt; x != null);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read the full article: &lt;a rel=&quot;nofollow&quot; href=&quot;https://chriseugenerodriguez.com/blog/tests-should-verify-runtime-contract-not-inferred-types&quot;&gt;https://chriseugenerodriguez.com/blog/tests-should-verify-runtime-contract-not-inferred-types&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30509/why-tests-should-verify-runtime-contracts-not-inferred-types</guid>
<pubDate>Fri, 09 Oct 2026 19:59:18 +0000</pubDate>
</item>
<item>
<title>Human Approval for AI Emails in n8n: Prevent Duplicate Sends</title>
<link>https://coderlegion.com/25670/human-approval-for-ai-emails-in-n8n-prevent-duplicate-sends</link>
<description>
&lt;p&gt;
        A safe AI email workflow creates a proposal, not a sent message. It gives that proposal a stable approval ID, stores it with a &lt;code&gt;pending&lt;/code&gt; state, shows a reviewer enough context to decide, waits for a response, and only then allows one guarded send.
      &lt;/p&gt;

      &lt;div class=&quot;article-callout&quot;&gt;
        &lt;p&gt;&lt;strong&gt;Partner disclosure.&lt;/strong&gt; This guide includes a partner link to &lt;a href=&quot;https://n8n.partnerlinks.io/6mg2xng7s1m6&quot; target=&quot;_blank&quot; rel=&quot;sponsored noopener nofollow&quot;&gt;n8n Cloud&lt;/a&gt;. If you sign up through it, I may earn a commission at no extra cost to you.&lt;/p&gt;
      &lt;/div&gt;

      &lt;div class=&quot;article-callout&quot;&gt;
        &lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; in n8n, use Gmail &lt;strong&gt;Send and Wait for Approval&lt;/strong&gt; for a simple approve-or-reject gate. Use a &lt;strong&gt;Wait&lt;/strong&gt; node with &lt;strong&gt;On Form Submitted&lt;/strong&gt; plus a Data Table when the reviewer also needs to edit, reject with a reason, or let the request expire.&lt;/p&gt;
      &lt;/div&gt;

      &lt;div class=&quot;article-table-wrap&quot;&gt;
        &lt;table class=&quot;article-table&quot;&gt;
          &lt;thead&gt;
            &lt;tr&gt;
              &lt;th&gt;Need&lt;/th&gt;
              &lt;th&gt;Best starting pattern&lt;/th&gt;
              &lt;th&gt;Important limit&lt;/th&gt;
            &lt;/tr&gt;
          &lt;/thead&gt;
          &lt;tbody&gt;
            &lt;tr&gt;
              &lt;td&gt;Approve or reject only&lt;/td&gt;
              &lt;td&gt;Gmail Message &amp;rarr; Send and Wait for Approval&lt;/td&gt;
              &lt;td&gt;Good for a simple decision, not a full edit-and-escalation process&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Approve, edit, reject, or timeout&lt;/td&gt;
              &lt;td&gt;Data Table + reviewer notification + Wait form&lt;/td&gt;
              &lt;td&gt;The form link is sensitive and the documented form mode has no authentication setting&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Identity-proof approval&lt;/td&gt;
              &lt;td&gt;Wait webhook with Basic, Header, or JWT auth, or an authenticated internal portal&lt;/td&gt;
              &lt;td&gt;More setup, but the reviewer identity is stronger than a typed email address&lt;/td&gt;
            &lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;

      &lt;h2 id=&quot;the-safe-workflow-architecture&quot;&gt;The safe workflow architecture&lt;/h2&gt;
      &lt;pre class=&quot;article-code&quot;&gt;Trigger or incoming request
  &amp;rarr; AI creates proposed subject and body
  &amp;rarr; Validate recipient, policy, and required fields
  &amp;rarr; Data Table: insert approval_id with status = pending
  &amp;rarr; Notify reviewer with safe context and resume URL
  &amp;rarr; Wait: On Form Submitted, with a time limit
  &amp;rarr; Switch: approve / edit / reject / timeout
  &amp;rarr; Data Table: pending &amp;rarr; sending
  &amp;rarr; Gmail: send once
  &amp;rarr; Data Table: sent, message_id, reviewer, timestamps&lt;/pre&gt;
      &lt;p&gt;
        This guide begins after an AI draft already exists. It is separate from &lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/n8n-ai-email-routing-philippines&quot;&gt;classifying and routing incoming email&lt;/a&gt;. The approval gate controls an outbound action that could reach a customer, supplier, applicant, or lead.
      &lt;/p&gt;

      &lt;h2 id=&quot;why-the-model-must-not-connect-directly-to-send&quot;&gt;Why the model must not connect directly to Send&lt;/h2&gt;
      &lt;p&gt;
        A language model can produce fluent text while using the wrong price, recipient, promise, date, or tone. Prompt instructions help, but they do not create an operational control. If the model node connects directly to Gmail Send, a bad draft can leave the business before anyone sees it.
      &lt;/p&gt;
      &lt;p&gt;
        Put validation and approval between generation and delivery. Validate machine-checkable rules first, such as an allowed recipient domain, non-empty subject, maximum length, banned attachments, and required account references. Then ask the human to judge the parts that need context.
      &lt;/p&gt;

      &lt;h2 id=&quot;step-1-start-with-the-simplest-n8n-setup&quot;&gt;Step 1: start with the simplest n8n setup&lt;/h2&gt;
      &lt;p&gt;
        For a small team that does not want to maintain a server, n8n Cloud is the easiest starting point. n8n describes Cloud as the option where it handles the infrastructure. Create one workflow, connect the AI provider you already use, add a Gmail credential, and test only with internal addresses until every branch behaves correctly.
      &lt;/p&gt;
      &lt;p&gt;
        If the decision is only approve or reject, test Gmail &lt;strong&gt;Message &amp;rarr; Send and Wait for Approval&lt;/strong&gt; first. The current Gmail node supports an Approve Only mode and an Approve and Disapprove mode. n8n itself recommends the Wait node for more complex approvals, which is the full pattern below.
      &lt;/p&gt;

      &lt;h2 id=&quot;step-2-turn-the-ai-output-into-a-real-draft-record&quot;&gt;Step 2: turn the AI output into a real draft record&lt;/h2&gt;
      &lt;p&gt;
        Add an &lt;strong&gt;Edit Fields&lt;/strong&gt; node after the model. Keep only the fields the approval stage needs, then generate a stable &lt;code&gt;approval_id&lt;/code&gt;. Use a UUID or another collision-resistant ID. Do not use the recipient address or current timestamp by itself.
      &lt;/p&gt;
      &lt;pre class=&quot;article-code&quot;&gt;approval_id
recipient
subject_proposed
body_proposed
source_record_id
status
created_at
expires_at&lt;/pre&gt;
      &lt;p&gt;
        Set &lt;code&gt;status = pending&lt;/code&gt;. Store the proposed subject and body in a Data Table before notifying anyone. This durable row lets an operator see what is waiting even when the workflow execution is paused or the reviewer email is lost.
      &lt;/p&gt;
      &lt;p&gt;
        Gmail also has a separate Draft resource. The current n8n Gmail Draft operations document create, delete, get, and list. They do not document a built-in Draft Send operation. Use a provider-side Gmail draft only as an optional inspectable copy unless you deliberately add the Gmail API &lt;code&gt;users.drafts.send&lt;/code&gt; call. Do not assume a Gmail Draft node automatically delivers the draft later.
      &lt;/p&gt;

      &lt;h2 id=&quot;step-3-create-the-approval-table&quot;&gt;Step 3: create the approval table&lt;/h2&gt;
      &lt;p&gt;For a first version, create an &lt;code&gt;email_approvals&lt;/code&gt; Data Table with these columns:&lt;/p&gt;
      &lt;pre class=&quot;article-code&quot;&gt;approval_id
source_record_id
recipient
subject_proposed
body_proposed
subject_approved
body_approved
status
reviewer
decision_reason
created_at
decided_at
sent_at
provider_message_id&lt;/pre&gt;
      &lt;p&gt;
        The Data Table node can insert, get, update, and upsert rows using conditions. That is enough for a small approval ledger. If the workflow has high concurrency, regulatory audit requirements, or several systems changing the same state, move the ledger to a database with transactions, unique constraints, and access controls.
      &lt;/p&gt;

      &lt;h2 id=&quot;step-4-notify-the-reviewer-and-pause&quot;&gt;Step 4: notify the reviewer and pause&lt;/h2&gt;
      &lt;p&gt;
        Send the reviewer an internal email containing the approval ID, recipient, proposed subject, a safe excerpt, expiry time, and the Wait form link. The Wait node exposes &lt;code&gt;$execution.resumeUrl&lt;/code&gt;, which is unique to the execution. The node that sends this link must run in the same execution as the Wait node.
      &lt;/p&gt;
      &lt;p&gt;Configure &lt;strong&gt;Wait &amp;rarr; On Form Submitted&lt;/strong&gt; with:&lt;/p&gt;
      &lt;ul&gt;
        &lt;li&gt;A required dropdown named &lt;code&gt;decision&lt;/code&gt; with &lt;code&gt;Approve&lt;/code&gt;, &lt;code&gt;Edit&lt;/code&gt;, and &lt;code&gt;Reject&lt;/code&gt;.&lt;/li&gt;
        &lt;li&gt;An optional text field for the approved subject.&lt;/li&gt;
        &lt;li&gt;An optional textarea for a revised body.&lt;/li&gt;
        &lt;li&gt;An optional rejection reason.&lt;/li&gt;
        &lt;li&gt;A reviewer identifier for the audit log.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Limit Wait Time&lt;/strong&gt; set to the business deadline, such as 24 hours.&lt;/li&gt;
      &lt;/ul&gt;
      &lt;p&gt;
        If the reviewer chooses Edit but provides no replacement body, do not send. Route the item back to &lt;code&gt;pending&lt;/code&gt; or a manual queue. n8n's form fields are not documented as conditionally required, so enforce this rule after submission with an If or Switch node.
      &lt;/p&gt;
      &lt;div class=&quot;article-callout&quot;&gt;
        &lt;p&gt;&lt;strong&gt;Treat the resume URL like a secret.&lt;/strong&gt; The Wait form documentation does not list the Basic, Header, or JWT authentication choices available in the webhook mode. Do not post the link in a broad channel. For sensitive approvals, use an authenticated webhook or internal portal and record the authenticated user.&lt;/p&gt;
      &lt;/div&gt;

      &lt;h2 id=&quot;step-5-branch-every-possible-outcome&quot;&gt;Step 5: branch every possible outcome&lt;/h2&gt;
      &lt;div class=&quot;article-table-wrap&quot;&gt;
        &lt;table class=&quot;article-table&quot;&gt;
          &lt;thead&gt;
            &lt;tr&gt;
              &lt;th&gt;Outcome&lt;/th&gt;
              &lt;th&gt;State change&lt;/th&gt;
              &lt;th&gt;Next action&lt;/th&gt;
            &lt;/tr&gt;
          &lt;/thead&gt;
          &lt;tbody&gt;
            &lt;tr&gt;
              &lt;td&gt;Approve&lt;/td&gt;
              &lt;td&gt;&lt;code&gt;pending &amp;rarr; approved&lt;/code&gt;&lt;/td&gt;
              &lt;td&gt;Copy the proposed subject and body into approved fields&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Edit&lt;/td&gt;
              &lt;td&gt;&lt;code&gt;pending &amp;rarr; approved_edited&lt;/code&gt;&lt;/td&gt;
              &lt;td&gt;Validate and store the human replacement before sending&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Reject&lt;/td&gt;
              &lt;td&gt;&lt;code&gt;pending &amp;rarr; rejected&lt;/code&gt;&lt;/td&gt;
              &lt;td&gt;Store the reason; never connect this branch to Send&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;No form data at the limit&lt;/td&gt;
              &lt;td&gt;&lt;code&gt;pending &amp;rarr; expired&lt;/code&gt;&lt;/td&gt;
              &lt;td&gt;Escalate or create a fresh approval request, never auto-approve&lt;/td&gt;
            &lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;
      &lt;p&gt;
        The Wait node automatically resumes when its configured time limit is reached. Check for a valid submitted decision before treating the execution as approved. Missing form data belongs on the timeout branch.
      &lt;/p&gt;
      &lt;p&gt;
        Email is the clearest reviewer channel for this example. Slack, Telegram, Google Chat, email, or a webhook can carry an alert, but do not assume every notification node has the same approval features. Keep the decision state and send guard in the workflow even when the alert channel changes.
      &lt;/p&gt;

      &lt;h2 id=&quot;step-6-prevent-duplicate-clicks-and-duplicate-sends&quot;&gt;Step 6: prevent duplicate clicks and duplicate sends&lt;/h2&gt;
      &lt;p&gt;
        Before Gmail Send, add a Data Table Update node with &lt;strong&gt;All Conditions&lt;/strong&gt;:
      &lt;/p&gt;
      &lt;pre class=&quot;article-code&quot;&gt;approval_id equals {{ approval_id }}
status equals approved OR approved_edited

Update:
status = sending&lt;/pre&gt;
      &lt;p&gt;
        In practice, use separate approve and edited branches if the node cannot express the OR cleanly. Continue only when one row was updated. If the row is already &lt;code&gt;sending&lt;/code&gt;, &lt;code&gt;sent&lt;/code&gt;, &lt;code&gt;rejected&lt;/code&gt;, or &lt;code&gt;expired&lt;/code&gt;, stop. This conditional claim protects the normal single-execution path from a second click, duplicated callback, or later retry.
      &lt;/p&gt;
      &lt;p&gt;
        After Gmail succeeds, update the same row to &lt;code&gt;sent&lt;/code&gt; and store the provider message ID and &lt;code&gt;sent_at&lt;/code&gt;. Do not manually retry from the Gmail node. Restart recovery from the state check so the send guard runs again.
      &lt;/p&gt;
      &lt;p&gt;
        n8n documents conditional Data Table updates, but it does not promise compare-and-set or transaction semantics across competing executions. If two executions could claim the same approval at exactly the same time, use a database transaction, unique constraint, or another state store with an atomic claim. For a Gmail-specific alternative, create one provider draft and send that immutable draft ID through the Gmail API. Google documents that sending automatically deletes the draft and creates the Sent message, so the same existing draft is no longer available for a second send.
      &lt;/p&gt;
      &lt;p&gt;
        Gmail Message Send does not expose a provider idempotency key in the documented node fields. Add the approval ID to an internal reference in the subject or footer so an operator can search Sent mail. If the network times out and you cannot tell whether Gmail accepted the message, set &lt;code&gt;status = send_unknown&lt;/code&gt;, search by that reference, and reconcile before any retry.
      &lt;/p&gt;

      &lt;h2 id=&quot;handle-failure-after-approval&quot;&gt;Handle failure after approval&lt;/h2&gt;
      &lt;div class=&quot;article-table-wrap&quot;&gt;
        &lt;table class=&quot;article-table&quot;&gt;
          &lt;thead&gt;
            &lt;tr&gt;
              &lt;th&gt;Failure&lt;/th&gt;
              &lt;th&gt;Safe response&lt;/th&gt;
            &lt;/tr&gt;
          &lt;/thead&gt;
          &lt;tbody&gt;
            &lt;tr&gt;
              &lt;td&gt;Recipient or payload validation fails&lt;/td&gt;
              &lt;td&gt;Return to review with a clear reason; do not claim the send&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Credential or definite provider rejection&lt;/td&gt;
              &lt;td&gt;Mark &lt;code&gt;send_failed&lt;/code&gt;, alert the owner, and repair before a guarded retry&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Provider timeout with an uncertain result&lt;/td&gt;
              &lt;td&gt;Mark &lt;code&gt;send_unknown&lt;/code&gt; and search the provider before retrying&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Email sends, audit update fails&lt;/td&gt;
              &lt;td&gt;Keep the row at &lt;code&gt;sending&lt;/code&gt;; reconcile by approval reference and provider message ID&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Reviewer link expires&lt;/td&gt;
              &lt;td&gt;Create a new approval request and invalidate the old one&lt;/td&gt;
            &lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;
      &lt;p&gt;
        This is the same reason duplicate-safe API retries need a persistent ledger. A timeout is not proof of failure. Use the deeper &lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/n8n-retry-failed-api-requests-without-duplicates&quot;&gt;n8n API retry guide&lt;/a&gt; when the final email provider is called through HTTP Request or another state-changing API.
      &lt;/p&gt;

      &lt;h2 id=&quot;test-the-approval-workflow-before-using-real-recipients&quot;&gt;Test the approval workflow before using real recipients&lt;/h2&gt;
      &lt;div class=&quot;article-table-wrap&quot;&gt;
        &lt;table class=&quot;article-table&quot;&gt;
          &lt;thead&gt;
            &lt;tr&gt;
              &lt;th&gt;Test&lt;/th&gt;
              &lt;th&gt;Expected result&lt;/th&gt;
            &lt;/tr&gt;
          &lt;/thead&gt;
          &lt;tbody&gt;
            &lt;tr&gt;
              &lt;td&gt;Approve unchanged&lt;/td&gt;
              &lt;td&gt;One email uses the proposed subject and body; audit row becomes &lt;code&gt;sent&lt;/code&gt;&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Edit subject and body&lt;/td&gt;
              &lt;td&gt;Only the human-approved values are delivered and stored&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Choose Edit with no body&lt;/td&gt;
              &lt;td&gt;No send; request returns to review&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Reject&lt;/td&gt;
              &lt;td&gt;No send; reason and reviewer are recorded&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Let the request expire&lt;/td&gt;
              &lt;td&gt;No send; status becomes &lt;code&gt;expired&lt;/code&gt; and the owner is alerted&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Submit or click twice&lt;/td&gt;
              &lt;td&gt;One email; if parallel tests produce more, move the claim to a transactional store&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Fail Gmail after approval&lt;/td&gt;
              &lt;td&gt;The workflow records failed or unknown, alerts an owner, and does not blind-retry&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Retry the execution&lt;/td&gt;
              &lt;td&gt;A sent or sending state blocks another message&lt;/td&gt;
            &lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;

      &lt;h2 id=&quot;privacy-and-audit-rules&quot;&gt;Privacy and audit rules&lt;/h2&gt;
      &lt;p&gt;
        The Wait node offloads paused execution data to the n8n database and reloads it when the workflow resumes. That means every upstream field carried into the wait can remain in stored execution data. Reduce the item before the wait. Do not carry a full mailbox thread, attachments, payment details, health information, or unrelated customer history when the reviewer only needs a short excerpt and a secure system link.
      &lt;/p&gt;
      &lt;ul&gt;
        &lt;li&gt;Store the approval ID, decision, approved content hash or final content, reviewer, and timestamps.&lt;/li&gt;
        &lt;li&gt;Treat a reviewer name typed into an open form as self-reported, not authenticated identity.&lt;/li&gt;
        &lt;li&gt;Use n8n's production execution data redaction when operators should not see node inputs and outputs.&lt;/li&gt;
        &lt;li&gt;Set a retention policy for the approval table and execution history.&lt;/li&gt;
        &lt;li&gt;Keep successful execution data only as long as operations and audit needs require.&lt;/li&gt;
      &lt;/ul&gt;
      &lt;p&gt;
        n8n enables pruning of finished execution data by default. Its documented self-hosted defaults are 14 days by age and 10,000 finished executions by count, while waiting executions are not eligible for pruning. The approval timeout is therefore an operating control as well as a reviewer deadline.
      &lt;/p&gt;

      &lt;h2 id=&quot;practical-questions&quot;&gt;Practical questions&lt;/h2&gt;
      &lt;h3&gt;Can the AI send automatically after enough approvals?&lt;/h3&gt;
      &lt;p&gt;
        You can later auto-send low-risk categories, but earn that exception with real review data. Start by measuring edit rate, rejection rate, send failures, and the types of mistakes reviewers catch. Keep high-value, legal, financial, hiring, complaint, and unusual-recipient messages behind approval.
      &lt;/p&gt;
      &lt;h3&gt;Should the approval live in email or chat?&lt;/h3&gt;
      &lt;p&gt;
        Use the channel the responsible person already monitors, but keep the durable state in the workflow or business system. An alert is not the audit record. For a simple decision, Gmail's built-in approval operation is convenient. For edits, timeouts, and stronger identity, use the Wait pattern or an authenticated portal.
      &lt;/p&gt;
      &lt;h3&gt;Is the n8n execution history enough for an audit?&lt;/h3&gt;
      &lt;p&gt;
        Usually not by itself. Execution retention can be changed or pruned, and open form identity may be self-reported. Keep a compact business audit row with the approval ID, final decision, final approved content or hash, reviewer identity level, timestamps, and provider result.
      &lt;/p&gt;

      &lt;h2&gt;Sources checked&lt;/h2&gt;
      &lt;ul&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/choose-n8n/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n Cloud and self-hosted setup choices&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n Wait node resume modes, unique resume URL, authentication, forms, and time limits&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.gmail/message-operations/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n Gmail Send and Wait for Approval and Message Send operations&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.gmail/draft-operations/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n Gmail Draft operations&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&amp;lt;a href=&quot;&lt;a href=&quot;https://docs.n8n.io/integrations/builtin/core-nodes/n8n-&quot; rel=&quot;nofollow&quot;&gt;https://docs.n8n.io/integrations/builtin/core-nodes/n8n-&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/25670/human-approval-for-ai-emails-in-n8n-prevent-duplicate-sends</guid>
<pubDate>Fri, 09 Oct 2026 15:50:00 +0000</pubDate>
</item>
<item>
<title>Demystifying ACID Transactions: How to Keep Your Database from Making a Mess</title>
<link>https://coderlegion.com/29382/demystifying-acid-transactions-how-to-keep-your-database-from-making-a-mess</link>
<description>&lt;p&gt;An ACID transaction is a set of four database safety rules—Atomicity, Consistency, Isolation, and Durability—that ensure database operations are executed reliably. It guarantees that even if your application crashes, loses power, or experiences thousands of concurrent requests, your data remains accurate and uncorrupted. Simply put, it ensures your database never ends up in a half-finished, broken state.&lt;/p&gt;
&lt;h4 id=&quot;the-ticket-booth-analogy&quot;&gt;The Ticket Booth Analogy&lt;/h4&gt;
&lt;p&gt;Imagine buying a physical ticket at a busy movie theater box office. You hand the cashier a $15 bill, and they hand you the ticket. This exchange must happen as a single, indivisible unit. If you hand over your cash, but a sudden power outage occurs before the cashier can hand you the ticket, you would be furious if they kept your money. Conversely, the theater would lose out if you grabbed the ticket and walked away without paying.&lt;/p&gt;
&lt;p&gt;The transaction is &quot;all-or-nothing&quot;: either the cash changes hands &lt;em&gt;and&lt;/em&gt; you get the ticket, or the entire deal is canceled, and you keep your money. ACID acts as this strict box-office coordinator for your software's database, ensuring that multi-step operations never stop halfway.&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters-real-world-disasters&quot;&gt;Why It Matters: Real-World Disasters&lt;/h4&gt;
&lt;p&gt;In daily software engineering, we rely on ACID transactions to prevent catastrophic data errors, especially when building systems like financial ledgers, e-commerce checkouts, or reservation platforms.&lt;/p&gt;
&lt;p&gt;Without ACID, imagine a bank transfer system built on Node.js. When a user transfers $100, the system must perform two separate actions: deduct $100 from Account A, and add $100 to Account B. If the database server loses connection exactly halfway through this process, Account A loses money, but Account B never receives it. The $100 vanishes into the digital void.&lt;/p&gt;
&lt;p&gt;Similarly, in a high-traffic retail store, if two customers try to buy the very last item in stock at the exact same millisecond, a non-ACID database might let both transactions complete. This leaves you with an oversold item and an angry customer. By grouping these operations inside an ACID transaction, developers ensure that if any step fails, the database automatically rolls back all changes, restoring everything to its original state.&lt;/p&gt;
&lt;h4 id=&quot;implementing-acid-in-node-js-and-mysql&quot;&gt;Implementing ACID in Node.js and MySQL&lt;/h4&gt;
&lt;p&gt;Here is how we can implement a secure money transfer using Node.js and the standard &lt;code&gt;mysql2/promise&lt;/code&gt; library. By wrapping our queries in a transaction block, we guarantee data safety.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const mysql = require('mysql2/promise');

async function transferFunds(fromAccountId, toAccountId, amount) {
  const connection = await mysql.createConnection({
    host: 'localhost',
    user: 'root',
    database: 'bank_db'
  });

  try {
    // Start the ACID transaction
    await connection.beginTransaction();

    // Step 1: Deduct from the sender's account
    const [deductResult] = await connection.execute(
      'UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance &amp;gt;= ?',
      [amount, fromAccountId, amount]
    );

    if (deductResult.affectedRows === 0) {
      throw new Error('Insufficient funds or invalid sender account');
    }

    // Step 2: Add to the receiver's account
    const [creditResult] = await connection.execute(
      'UPDATE accounts SET balance = balance + ? WHERE id = ?',
      [amount, toAccountId]
    );

    if (creditResult.affectedRows === 0) {
      throw new Error('Invalid receiver account');
    }

    // If both steps succeed, permanently commit the changes to disk
    await connection.commit();
    console.log('Transaction completed successfully!');
  } catch (error) {
    // If anything fails, revert all changes immediately
    await connection.rollback();
    console.error('Transaction failed. Rolled back changes:', error.message);
  } finally {
    await connection.end();
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-takeaway&quot;&gt;The Takeaway&lt;/h4&gt;
&lt;p&gt;ACID transactions are the ultimate insurance policy for your application's state. While they introduce a minor performance trade-off due to locking rows during updates, they completely eliminate the need to write complex, error-prone fallback code. Shifting the responsibility of data integrity to your database engine lets you sleep easy knowing your system will never tell a lie.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/10/data-integrity-101-why-acid.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29382/demystifying-acid-transactions-how-to-keep-your-database-from-making-a-mess</guid>
<pubDate>Thu, 01 Oct 2026 10:28:33 +0000</pubDate>
</item>
<item>
<title>Improve your Angular app performance by using this simple Observable cache decorator 
</title>
<link>https://coderlegion.com/29028/improve-your-angular-app-performance-by-using-this-simple-observable-cache-decorator</link>
<description>&lt;p&gt;When we were about to finish development of our applications in SwiftViews we noticed a pattern in all our data-fetching user flows. In spite of the fact that the apps are all data-driven and looked really dynamic, what really was changing in the same user session wasn’t that much, but we were making http requests for new content regardless.&lt;br&gt;
The easiest solution was caching&lt;br&gt;
Caching where? On the server? We already have that, but this doesn’t stop all our apps hitting our services and thus — increasing the load on them.&lt;br&gt;
Maybe we could use service worker since it allows caching API calls?&lt;br&gt;
Yes, that was one of the options since it already has a pretty nice integration with Angular and allowed for a simple solution to selectively cache resources and APIs. However, what we wanted is to be able to not only choose what to cache, but also when to do it.&lt;br&gt;
Just to give you a quick example — we basically wanted to cache all API call results for the page below, but only if we were certain that the data source did not change in some way.&lt;br&gt;
&lt;img src=&quot;https://thepracticaldev.s3.amazonaws.com/i/x5df5ibqm2u4nqndgtz1.png&quot; alt=&quot;Inventory&quot;&gt;&lt;/p&gt;
&lt;p&gt;Since this is my personal inventory page, the only way it can actually change is if I add an item from this platform (currently the only way to do so), so I have the information about when this page will change and how long I can return cached content for.&lt;br&gt;
We figured the best way to apply this caching in a selective and configurable way will be to use what the platform already gives us&lt;br&gt;
Since we are using Angular and Typescript ❤️, and all our data calls go through RxJs, we figured that we can create a Cache Observable decorator, which we’d use to only give the caching power to certain methods.&lt;br&gt;
So, before, this was our method which called our server to get the products on the page above:&lt;/p&gt;
&lt;p&gt;and that became:&lt;/p&gt;
&lt;p&gt;Notice that the @Cacheable() has been applied just to the method we wanted and was also passed cacheBusterObserver which is basically our Subject-based mechanism to tell this exact decorated method to relieve all its caches, when any value is emitted in that stream.&lt;br&gt;
For example, the method below will “cache-bust” the caches of the method above, if the Observable it returns emits a value &lt;/p&gt;
&lt;p&gt;This might be the most complex example we have but we also have other configurations implemented like maxCacheCount, maxAge, slidingExpiration and custom cache deciders and resolvers which give us a fine-grain control on what, when and how to cache. See those in the README file.&lt;br&gt;
In the two gifs below, you can see the difference between our app’s performance without and with caching.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.loom.com/share/71a03e4cd478407fa53f131fc112e09a&quot;&gt;https://www.loom.com/share/71a03e4cd478407fa53f131fc112e09a&lt;/a&gt;&lt;br&gt;
No Cacheable&lt;br&gt;
The cacheable decorator is not applied yet, so every page load will actually fetch data from the server&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.loom.com/share/380a1fa93a9c42d0a5adaa987cd51efb&quot;&gt;https://www.loom.com/share/380a1fa93a9c42d0a5adaa987cd51efb&lt;/a&gt;&lt;br&gt;
Cacheable decorator applied &lt;br&gt;
The cacheable decorator is applied and we can see that all consecutive page loads are faster. Also the user profile page loads instantly, because we have already called the /user/{id}endpoint on the inventory page. Also, the cache busts after we add a new item so our user gets fresh data&lt;br&gt;
So, to sum up, this simple decorator allowed us to:&lt;br&gt;
Selectively cache observable methods (not just endpoints, but maybe also computation-heavy calculations in streams)&lt;br&gt;
Improve the performance of our app, without any business logic refactoring (thanks TS decorators ❤️)&lt;br&gt;
Greatly reduce the load on our servers&lt;br&gt;
If you want to use this decorator in your project, just install it from NPM!&lt;br&gt;
npm install ngx-cacheable&lt;br&gt;
If you have any questions about it or want to contribute, don’t mind opening a pull request @github or commenting below.&lt;br&gt;
Also, if you liked the application, please register and build your own inventory @ swiftviews.&lt;br&gt;
Thank you! &lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29028/improve-your-angular-app-performance-by-using-this-simple-observable-cache-decorator</guid>
<pubDate>Tue, 29 Sep 2026 10:53:16 +0000</pubDate>
</item>
<item>
<title>Npm err! maximum call stack size exceeded</title>
<link>https://coderlegion.com/28792/npm-err-maximum-call-stack-size-exceeded</link>
<description>&lt;p&gt;
&lt;strong&gt;One of the most common issues that developers encounter while working with npm (Node Package Manager) is the &lt;span style=&quot;color: #ff04fc;&quot;&gt;err! maximum call stack size exceeded&lt;/span&gt; error. This error can be frustrating and confusing, but don't worry, you're not alone! In this article, we'll take a deep dive into what causes this error and how to fix it. So, whether you're a seasoned developer or just getting started with Node.js, read on to learn more about this common issue and how to resolve it.
&lt;/strong&gt;
&lt;/p&gt;&lt;hr&gt;
&lt;div id=&quot;toc-div&quot;&gt;
&lt;h3&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;&lt;strong&gt; Table of Contents:&lt;a id=&quot;toc-toogle&quot; class=&quot;open&quot;&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;div id=&quot;toc-list&quot;&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#what-is-``call-stack-size``&quot;&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;What is &lt;span style=&quot;color: #ff04fc;&quot;&gt;call stack size&lt;/span&gt;?&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#why-the-error-npm-err!-maximum-call-stack-size-exceeded&quot;&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;Why the error: Npm err! maximum call stack size exceeded&lt;/span&gt;&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#recursive-dependencies&quot;&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;Recursive dependencies&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#large-dependency-trees&quot;&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;Large dependency trees&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#installing-global-packages&quot;&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;Installing global packages&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#recursive-function-calls&quot;&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;Recursive function calls&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#a-large-number-of-nested-function-calls&quot;&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;A large number of nested function calls&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#how-to-resolve-the-error&quot;&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;How to resolve the error&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#conclusion&quot;&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;Conclusion&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#references&quot;&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;References&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h3&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;What is &lt;span style=&quot;color: #ff04fc;&quot;&gt;call stack size&lt;/span&gt;?&lt;/span&gt;&lt;a id=&quot;what-is-``call-stack-size``&quot; class=&quot;custom_anchor&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Npm (short for Node Package Manager) is a package manager for the JavaScript programming language. It is the default package manager for the JavaScript runtime environment Node.js.&lt;/p&gt;
&lt;p&gt;One common issue that users may encounter when using npm is the &lt;span style=&quot;color: #ff04fc;&quot;&gt;npm ERR! maximum call stack size exceeded&lt;/span&gt; error. This error occurs when the program's call stack size exceeds the maximum limit, causing the program to crash.&lt;/p&gt;
&lt;p&gt;The call stack is a memory structure used by the JavaScript engine to track the execution of a program. Each time a function is called, a new frame is added to the stack, and when the function returns, the frame is removed. When the stack size exceeds the maximum limit, the program crashes to prevent a potential crash.&lt;/p&gt;
&lt;p&gt;The &lt;span style=&quot;color: #ff04fc;&quot;&gt;npm err! maximum call stack size exceeded&lt;/span&gt; error is a common issue that can occur when using the Node Package Manager (npm) to install and manage packages for JavaScript projects. The error is caused by a maximum limit on the number of function calls that can be made in a single execution stack, which is known as the call stack limit. When this limit is exceeded, the error is thrown and the process is terminated.&lt;/p&gt;
&lt;h3&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;Why the error: Npm err! maximum call stack size exceeded&lt;/span&gt;&lt;a id=&quot;why-the-error-npm-err!-maximum-call-stack-size-exceeded&quot; class=&quot;custom_anchor&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;There are a few different ways that this error can occur, but some common causes include:&lt;/p&gt;
&lt;h3&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;Recursive dependencies&lt;/span&gt;&lt;a id=&quot;recursive-dependencies&quot; class=&quot;custom_anchor&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;When a package has a dependency on another package, and that dependency in turn has a dependency on the first package, it can create a recursive loop that exceeds the call stack limit. An illustration of such a scenario can be seen below.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;
// moduleA.js
const moduleB = require('./moduleB');

// moduleB.js
const moduleA = require('./moduleA');
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;Large dependency trees&lt;/span&gt;&lt;a id=&quot;large-dependency-trees&quot; class=&quot;custom_anchor&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;When a project has a large number of dependencies, the process of resolving and installing these dependencies can exceed the call stack limit.&lt;/p&gt;
&lt;h3&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;Installing global packages&lt;/span&gt;&lt;a id=&quot;installing-global-packages&quot; class=&quot;custom_anchor&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;When installing packages globally using the &lt;span style=&quot;color: #ff04fc;&quot;&gt;&lt;code&gt;-g&lt;/code&gt;&lt;/span&gt; flag, the process can exceed the call stack limit if the package being installed has a large number of dependencies.&lt;/p&gt;
&lt;h3&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;Recursive function calls&lt;/span&gt;&lt;a id=&quot;recursive-function-calls&quot; class=&quot;custom_anchor&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;When a function calls itself without a proper exit condition, it creates a new frame on the stack each time, causing the stack size to grow indefinitely. An illustration can be seen in the code below:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;
function infiniteRecursion() {
infiniteRecursion();
}
infiniteRecursion();
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;A large number of nested function calls&lt;/span&gt;&lt;a id=&quot;a-large-number-of-nested-function-calls&quot; class=&quot;custom_anchor&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;When a program has a large number of nested function calls, it can cause the stack size to grow quickly, eventually exceeding the maximum limit.&lt;/p&gt;
&lt;h3&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;How to resolve the error&lt;/span&gt;&lt;a id=&quot;how-to-resolve-the-error&quot; class=&quot;custom_anchor&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;There are several ways to fix this issue, some of which include:&lt;br&gt;
&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;b&gt;Updating npm&lt;/b&gt;: Updating to the latest version of npm can help resolve the issue, as new versions may have better handling of large dependency trees and recursive dependencies.&lt;/li&gt;&lt;li&gt;&lt;b&gt;Removing unnecessary dependencies&lt;/b&gt;: Removing unnecessary dependencies from the project can help reduce the size of the dependency tree and lower the chance of exceeding the call stack limit.&lt;/li&gt;&lt;li&gt;&lt;b&gt;Using the &lt;span style=&quot;color: #ff04fc;&quot;&gt;&lt;code&gt;–max-old-space-size flag&lt;/code&gt;&lt;/span&gt;&lt;/b&gt;: Using this flag can increase the amount of memory available to the Node.js process, allowing it to handle larger dependency trees without exceeding the call stack limit. &lt;span style=&quot;color: #ff04fc;&quot;&gt;&lt;code&gt;NODE_OPTIONS=--max-old-space-size=&amp;lt;enter_size_here&amp;gt;&lt;/code&gt;&lt;/span&gt;&lt;/li&gt;&lt;li&gt;Identifying the cause of the error by reviewing the code and looking for any potential sources of recursion or circular dependencies.&lt;/li&gt;&lt;li&gt;Refactor the code to remove recursion or circular dependencies.&lt;/li&gt;&lt;li&gt;Break down large functions into smaller, more manageable functions to reduce the number of nested function calls.&lt;/li&gt;&lt;li&gt;&lt;b&gt;Using a package manager like Yarn&lt;/b&gt;: Yarn is another package manager that can be used instead of npm, which can handle large dependency trees more efficiently.&lt;/li&gt;&lt;/ul&gt;
&lt;div class=&quot;div-purple&quot;&gt;
&lt;span class=&quot;alert-header&quot;&gt;Note&lt;/span&gt;
&lt;span class=&quot;alert-body&quot;&gt;It is important to remember that this error may also occur due to the system limitations and not just the package manager. So, it is best to check the system requirements before proceeding with the project.&lt;/span&gt;
&lt;/div&gt;
&lt;h3&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;Conclusion&lt;/span&gt;&lt;a id=&quot;conclusion&quot; class=&quot;custom_anchor&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The &lt;span style=&quot;color: #ff04fc;&quot;&gt;npm err! maximum call stack size exceeded&lt;/span&gt; error is a common issue that can occur when using the Node Package Manager to install and manage packages for JavaScript projects. The error is caused by a maximum limit on the number of function calls that can be made in a single execution stack and can occur due to recursive dependencies, large dependency trees, and installing global packages. There are several ways to fix this issue, such as updating npm, removing unnecessary dependencies, using the &lt;span style=&quot;color: #ff04fc;&quot;&gt;&lt;code&gt;–max-old-space-size flag&lt;/code&gt;&lt;/span&gt;, and using a package manager like Yarn.&lt;/p&gt;
&lt;h3&gt;&lt;span style=&quot;font-family:arial,helvetica,sans-serif;&quot;&gt;References&lt;/span&gt;&lt;a id=&quot;references&quot; class=&quot;custom_anchor&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.npmjs.com/files/npm-debug.log&quot;&gt;https://docs.npmjs.com/files/npm-debug.log&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://nodejs.org/en/docs/guides/debugging-getting-started/&quot;&gt;https://nodejs.org/en/docs/guides/debugging-getting-started/&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.npmjs.com/cli/install&quot;&gt;https://docs.npmjs.com/cli/install&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://yarnpkg.com/getting-started&quot;&gt;https://yarnpkg.com/getting-started&lt;/a&gt;&lt;br&gt;
JavaScript Call Stack: &lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.bitsrc.io/understanding-the-javascript-call-stack-f9ce69e5ce9c&quot;&gt;https://blog.bitsrc.io/understanding-the-javascript-call-stack-f9ce69e5ce9c&lt;/a&gt;&lt;br&gt;
Increasing the call stack size limit: &lt;a rel=&quot;nofollow&quot; href=&quot;https://nodejs.org/en/docs/guides/debugging-getting-started/#increase-the-call-stack-size-limit&quot;&gt;https://nodejs.org/en/docs/guides/debugging-getting-started/#increase-the-call-stack-size-limit&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28792/npm-err-maximum-call-stack-size-exceeded</guid>
<pubDate>Sun, 27 Sep 2026 09:49:04 +0000</pubDate>
</item>
<item>
<title>I built an RPG that teaches Claude Code by making you actually use it</title>
<link>https://coderlegion.com/28724/i-built-an-rpg-that-teaches-claude-code-by-making-you-actually-use-it</link>
<description>&lt;hr&gt;
&lt;p&gt;title: I built an RPG that teaches Claude Code by making you actually use it&lt;br&gt;
published: true&lt;br&gt;
description: claude-quest is an in-terminal RPG for learning the Claude Code CLI. missions live in real sandbox directories, and progress is graded by checking what you actually did, not by quizzing you.&lt;br&gt;
tags: ai, claude, opensource, node&lt;/p&gt;
&lt;h3 id=&quot;series-building-claude-quest&quot;&gt;series: Building claude-quest&lt;/h3&gt;
&lt;p&gt;Most tool documentation teaches by just telling you things. You read a page about&lt;br&gt;
&lt;code&gt;/model&lt;/code&gt;, or hooks, or subagents, nod along, and forget it by the time you'd&lt;br&gt;
actually need it. I wanted something closer to how people actually learn a CLI,&lt;br&gt;
by using it, with something checking whether you did the thing right or not.&lt;/p&gt;
&lt;p&gt;So I built &lt;strong&gt;claude-quest&lt;/strong&gt;, a text RPG that runs entirely inside a real&lt;br&gt;
&lt;code&gt;claude&lt;/code&gt; session and teaches the Claude Code CLI zero to hero.&lt;/p&gt;
&lt;h3 id=&quot;the-idea-isn-t-new-i-borrowed-it&quot;&gt;the idea isn't new, I borrowed it&lt;/h3&gt;
&lt;p&gt;This is basically &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/phyver/GameShell&quot;&gt;GameShell&lt;/a&gt;'s philosophy&lt;br&gt;
applied to Claude Code. GameShell teaches Unix shell commands by dropping you&lt;br&gt;
into a real shell wrapped in a themed fake filesystem, and grading your&lt;br&gt;
progress by checking real shell/filesystem state instead of asking you to self&lt;br&gt;
report or answer a quiz question.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;claude-quest&lt;/code&gt; does the same thing, except the &quot;filesystem&quot; is a real Claude&lt;br&gt;
Code environment. missions live in real sandbox directories, and progress is&lt;br&gt;
checked by inspecting what you actually did, config files you wrote, hooks&lt;br&gt;
that fired, tool calls that happened.&lt;/p&gt;
&lt;h3 id=&quot;what-it-actually-looks-like&quot;&gt;what it actually looks like&lt;/h3&gt;
&lt;p&gt;There's no separate app, no fancy terminal UI, none of that. You say &quot;let's&lt;br&gt;
play claude quest&quot; inside a normal &lt;code&gt;claude&lt;/code&gt; session, and Claude itself becomes&lt;br&gt;
the game master. It reads real output from the engine and narrates on top of&lt;br&gt;
it, something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;&amp;gt; lets play claude quest

**The Gatehall**

You've just been let through the outer gate. The tower keeps no secrets
from those who bother to read the walls...

**Mission: First Contact** (Tier 1, Artifact)

Create a CLAUDE.md file in this sandbox recording how to run the
project's tests. Let me know when you're done and I'll check it.

&amp;gt; done

CLAUDE.md records how to run the tests. Claude Code will know next time.
MISSION_STATUS: complete

**What you actually learned:** Claude Code reads a file named CLAUDE.md
in your project root automatically, at the start of every session...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;that's it, that's the whole interface. it's just a chat, with real commands&lt;br&gt;
running underneath it and real files getting checked. nothing gets rendered,&lt;br&gt;
nothing gets animated, it's plain text the way &lt;code&gt;claude&lt;/code&gt; sessions already look.&lt;/p&gt;
&lt;h3 id=&quot;missions-are-graded-on-real-state-not-vibes&quot;&gt;missions are graded on real state, not vibes&lt;/h3&gt;
&lt;p&gt;Every mission is one of four tiers, and the grading gets less mechanical the&lt;br&gt;
further you go:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Artifact&lt;/strong&gt;, write or edit a file (a &lt;code&gt;CLAUDE.md&lt;/code&gt;, a config) and the engine&lt;br&gt;
checks the file itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Invocation&lt;/strong&gt;, actually run a &lt;code&gt;claude&lt;/code&gt; session and do the thing (switch&lt;br&gt;
models, compact, check cost). the engine checks what landed afterward&lt;br&gt;
(like &lt;code&gt;.claude/settings.json&lt;/code&gt;), never what you said in the session.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mastery&lt;/strong&gt;, fix something that's conceptually broken (a skill whose&lt;br&gt;
description doesn't say what its for, a subagent that won't fire), then&lt;br&gt;
the engine runs a held-out test battery against your own &lt;code&gt;claude&lt;/code&gt; CLI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Judgment&lt;/strong&gt;, write something graded on a rubric (a commit message, a&lt;br&gt;
skill description) and an isolated judge call scores it per criteria.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;None of these ask you to type an answer into a prompt, they ask you to leave&lt;br&gt;
a trace and then the engine checks the trace.&lt;/p&gt;
&lt;h3 id=&quot;try-it&quot;&gt;try it&lt;/h3&gt;
&lt;p&gt;Inside any &lt;code&gt;claude&lt;/code&gt; session:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;/plugin marketplace add susheem-k/claude-quest
/plugin install claude-quest@claude-quest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then just say &quot;let's play claude quest.&quot; Progress saves to &lt;code&gt;~/.claude-quest/&lt;/code&gt;,&lt;br&gt;
so it doesn't really matter which project your in when you pick it back up.&lt;/p&gt;
&lt;h3 id=&quot;where-it-s-at-not-all-the-levels-exist-yet&quot;&gt;where it's at, not all the levels exist yet&lt;/h3&gt;
&lt;p&gt;This is a playable alpha, not a finished game. The full loop already works&lt;br&gt;
(characters, saves, hints, grading for all four tiers) but the campaign&lt;br&gt;
doesn't cover everything Claude Code can do yet. Right now there's only a&lt;br&gt;
handful of example missions, one or two per tier, across five arcs. More&lt;br&gt;
missions are getting added.&lt;/p&gt;
&lt;p&gt;And since this is open source, that part doesn't have to be just me. If&lt;br&gt;
there's a Claude Code feature you wish more people knew how to use, a&lt;br&gt;
command, a hook, a workflow you had to learn the hard way, I'd genuinely like to turn it into a mission. The mission format is just a directory with a goal, a hint, and a &lt;code&gt;check.js&lt;/code&gt; (the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/susheem-k/claude-quest/blob/main/docs/DESIGN.md&quot;&gt;design doc&lt;/a&gt; has the exact file contract for it), so adding one doesn't require touching&lt;br&gt;
the engine at all.&lt;/p&gt;
&lt;p&gt;Open an &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/susheem-k/claude-quest/issues&quot;&gt;issue&lt;/a&gt; with a&lt;br&gt;
mission idea, or just send a PR with one built already, both are welcome.&lt;/p&gt;
&lt;p&gt;if you try it and it teaches you something, a star on the repo genuinely&lt;br&gt;
helps, it's how other people find it.&lt;/p&gt;
&lt;p&gt;Repo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/susheem-k/claude-quest&quot;&gt;https://github.com/susheem-k/claude-quest&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28724/i-built-an-rpg-that-teaches-claude-code-by-making-you-actually-use-it</guid>
<pubDate>Sat, 26 Sep 2026 14:26:04 +0000</pubDate>
</item>
<item>
<title>Route flags, context decorators and typed assets in KickJS: declare it once, use it everywhere</title>
<link>https://coderlegion.com/28152/route-flags-context-decorators-and-typed-assets-in-kickjs-declare-it-once-use-everywhere</link>
<description>&lt;p&gt;Every API accumulates the same kind of glue. &quot;This endpoint is public&quot; gets written three times — once for auth, once for CSRF, once for rate limiting — and two of those are path strings that break the day someone renames a route. &quot;Who is the current user&quot; becomes &lt;code&gt;req.user as any&lt;/code&gt;. &quot;Where is the welcome email template&quot; becomes &lt;code&gt;__dirname&lt;/code&gt; arithmetic that works in dev and fails in the Docker image.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/&quot;&gt;KickJS&lt;/a&gt; has three features that remove that glue, and they are far more useful &lt;strong&gt;together&lt;/strong&gt; than apart:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Feature &lt;/th&gt;&lt;th&gt;What it answers &lt;/th&gt;&lt;th&gt;Mental model&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Route flags&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;em&gt;What is true about this route?&lt;/em&gt; &lt;/td&gt;&lt;td&gt;A fact, written once on the route, read by anything. No behaviour.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Context decorators&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;em&gt;What do we know about this request?&lt;/em&gt; &lt;/td&gt;&lt;td&gt;A typed value computed per request, before the handler runs.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Asset manager&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;em&gt;Where is that file?&lt;/em&gt; &lt;/td&gt;&lt;td&gt;A typed path to a file shipped with your code, correct in dev and in the build.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This guide explains each one, then builds a small feature set that uses all three at once: public and private routes, a signed-in user, plan-gated endpoints, per-route rate limits, and locale-aware emails and reports rendered from typed templates.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Versions: &lt;code&gt;@forinda/kickjs&lt;/code&gt; 8.4+, &lt;code&gt;@forinda/kickjs-cli&lt;/code&gt; 8.2+, &lt;code&gt;@forinda/kickjs-testing&lt;/code&gt; 8.0+. All three features are in the core package — nothing extra to install.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-route-flags-facts-about-a-route&quot;&gt;1. Route flags: facts about a route&lt;/h3&gt;
&lt;p&gt;A route flag is a &lt;strong&gt;named, inheritable fact&lt;/strong&gt; about a route. It does nothing by itself; it records something any consumer can read.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/flags.ts
import { defineRouteFlag } from '@forinda/kickjs'

export const Public = defineRouteFlag('auth.public')
export const RateLimit = defineRouteFlag&amp;lt;{ rpm: number }&amp;gt;('rate.limit')
export const Feature = defineRouteFlag&amp;lt;string&amp;gt;('billing.feature')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put flags on a controller, a method, or a module mount. The most specific declaration wins — &lt;strong&gt;method &amp;gt; class &amp;gt; mount&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Public // every route in this controller is public…
@Controller()
export class DocsController {
  @Get('/')
  index(ctx: RequestContext) {} // public

  @Public.off // …except this one
  @Get('/drafts')
  drafts(ctx: RequestContext) {} // not public
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read them anywhere you hold a &lt;code&gt;RequestContext&lt;/code&gt; — handlers, &lt;code&gt;@Middleware()&lt;/code&gt;, guards, contributors:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;ctx.route?.flags.has('auth.public') // true / false
ctx.route?.flags.get('rate.limit') // { rpm: 10 } or undefined
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two rules make flags predictable:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Removal is &lt;code&gt;.off&lt;/code&gt;, never a falsy value.&lt;/strong&gt; A flag is either present or absent. &lt;code&gt;@Public.off&lt;/code&gt; removes it; there is no &quot;present but false&quot; state that a presence check could misread as &quot;public&quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;false&lt;/code&gt; is a real value.&lt;/strong&gt; A flag declared with a boolean type can store &lt;code&gt;false&lt;/code&gt;, and &lt;code&gt;flags.get()&lt;/code&gt; returns it. Only &lt;code&gt;.off&lt;/code&gt; removes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;ctx.route&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt; in global middleware, which runs before a route is matched (section 8 shows how pre-routing middleware reads flags anyway).&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-context-decorators-typed-values-about-a-request&quot;&gt;2. Context decorators: typed values about a request&lt;/h3&gt;
&lt;p&gt;A context decorator (also called a &lt;em&gt;contributor&lt;/em&gt;) computes a value from the request and stores it on the context &lt;strong&gt;before the handler runs&lt;/strong&gt;. It replaces middleware whose only job was &quot;compute X and stash it on the request&quot;.&lt;/p&gt;
&lt;p&gt;Scaffold one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kick g contributor locale --dry-run
kick g contributor locale -o src/contributors
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fill it in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/contributors/locale.contributor.ts
import { defineHttpContextDecorator } from '@forinda/kickjs'

// 1. Declare the key's type once, for the whole app.
declare module '@forinda/kickjs' {
  interface ContextMeta {
    locale: { language: string; region: string | null }
  }
}

// 2. Define how it's computed.
export const ResolveLocale = defineHttpContextDecorator({
  key: 'locale',
  resolve: (ctx) =&amp;gt; {
    const header = (ctx.req.headers['accept-language'] as string | undefined) ?? 'en'
    const [language, region] = header.split(',')[0].trim().split('-')
    return { language: language.toLowerCase(), region: region ?? null }
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@ResolveLocale
@Get('/home')
home(ctx: RequestContext) {
  const locale = ctx.require('locale') // { language: string; region: string | null }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What you get over hand-written middleware:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Concern &lt;/th&gt;&lt;th&gt;Middleware &lt;/th&gt;&lt;th&gt;Context decorator&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Type of the value &lt;/td&gt;&lt;td&gt;&lt;code&gt;(req as any).locale&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;ctx.require('locale')&lt;/code&gt;, typed from &lt;code&gt;ContextMeta&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Ordering &lt;/td&gt;&lt;td&gt;Array position in &lt;code&gt;bootstrap()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;dependsOn: ['user']&lt;/code&gt;, sorted at boot; cycles and missing producers &lt;strong&gt;fail startup&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Services &lt;/td&gt;&lt;td&gt;&lt;code&gt;Container.getInstance()&lt;/code&gt; inside the body &lt;/td&gt;&lt;td&gt;&lt;code&gt;deps: { repo: REPO_TOKEN }&lt;/code&gt;, resolved and typed&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Per-route opt-out &lt;/td&gt;&lt;td&gt;Fork the stack or check paths &lt;/td&gt;&lt;td&gt;&lt;code&gt;skipWhen&lt;/code&gt; / &lt;code&gt;onlyWhen&lt;/code&gt; with route flags&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Errors &lt;/td&gt;&lt;td&gt;Throw → 500 &lt;/td&gt;&lt;td&gt;&lt;code&gt;optional: true&lt;/code&gt;, &lt;code&gt;onError&lt;/code&gt; fallback, or &lt;code&gt;HttpException&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Testing &lt;/td&gt;&lt;td&gt;Whole HTTP stack &lt;/td&gt;&lt;td&gt;&lt;code&gt;runContributor()&lt;/code&gt; against a fake context&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Registration sites, highest precedence first: &lt;strong&gt;method &amp;gt; class &amp;gt; module &lt;code&gt;contributors()&lt;/code&gt; &amp;gt; adapter/plugin &lt;code&gt;contributors()&lt;/code&gt; &amp;gt; &lt;code&gt;bootstrap({ contributors })&lt;/code&gt;&lt;/strong&gt;. A narrower registration of the same key silently replaces a broader one.&lt;/p&gt;
&lt;p&gt;Reading values:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ctx.require('key')&lt;/code&gt; — for values the route &lt;strong&gt;must&lt;/strong&gt; have. Throws &lt;code&gt;MissingContextValueError&lt;/code&gt; naming the key and route if the contributor didn't run. Never write &lt;code&gt;ctx.get('key')!&lt;/code&gt;: it compiles even after someone deletes the decorator.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ctx.get('key')&lt;/code&gt; — for optional extras. Returns &lt;code&gt;T | undefined&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getRequestValue('key')&lt;/code&gt; — from a &lt;strong&gt;service&lt;/strong&gt; with no &lt;code&gt;ctx&lt;/code&gt; in scope. Returns &lt;code&gt;undefined&lt;/code&gt; outside a request.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;Always &lt;strong&gt;return&lt;/strong&gt; the value from &lt;code&gt;resolve()&lt;/code&gt;. Assigning &lt;code&gt;ctx.locale = …&lt;/code&gt; writes to one internal context object that the handler never sees.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-the-asset-manager-typed-paths-to-your-files&quot;&gt;3. The asset manager: typed paths to your files&lt;/h3&gt;
&lt;p&gt;Templates, report layouts, JSON schemas and fixtures live next to your code in development and get copied somewhere else in the build. Without help, every call site does this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const path =
  process.env.NODE_ENV === 'production'
    ? join(__dirname, '../templates/mails/welcome.ejs')
    : join(__dirname, '../../src/templates/mails/welcome.ejs')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Typos compile, every call site branches on the environment, and &lt;code&gt;__dirname&lt;/code&gt; breaks under ESM and bundling.&lt;/p&gt;
&lt;h4 id=&quot;configure&quot;&gt;Configure&lt;/h4&gt;
&lt;p&gt;Tell the CLI which directories hold assets, in &lt;code&gt;kick.config.ts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { defineConfig } from '@forinda/kickjs-cli'

export default defineConfig({
  assetMap: {
    mails: { src: 'src/templates/mails', glob: '**/*.ejs' },
    reports: { src: 'src/templates/reports', glob: '**/*.{ejs,html}' },
    schemas: { src: 'src/schemas', glob: '**/*.json' },
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each entry takes &lt;code&gt;src&lt;/code&gt; (required), &lt;code&gt;dest&lt;/code&gt; (default &lt;code&gt;dist/&amp;lt;name&amp;gt;/&lt;/code&gt;), &lt;code&gt;glob&lt;/code&gt; (default &lt;code&gt;**/*&lt;/code&gt;) and &lt;code&gt;keys&lt;/code&gt; (&lt;code&gt;'auto'&lt;/code&gt;, &lt;code&gt;'strip'&lt;/code&gt; or &lt;code&gt;'with-extension'&lt;/code&gt; — how file names become property names).&lt;/p&gt;
&lt;h4 id=&quot;generate-types&quot;&gt;Generate types&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kick typegen   # kick dev runs this automatically on every change
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a file tree like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;src/templates/mails/en/welcome.ejs
src/templates/mails/fr/welcome.ejs
src/templates/reports/monthly.ejs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;you get a typed surface:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { assets } from '@forinda/kickjs'

assets.mails.en.welcome() // absolute path to the right file, dev or build
assets.reports.monthly()
assets.mails.en.welcom() // compile error
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;four-ways-to-read-an-asset&quot;&gt;Four ways to read an asset&lt;/h4&gt;
&lt;p&gt;All four go through the same cached resolver:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { assets, useAssets, Asset, resolveAsset } from '@forinda/kickjs'

assets.reports.monthly() // 1. ambient proxy — the default

class ReportService {
  private assets = useAssets() // 2. as a field — easy to mock in tests
}

@Service()
class InvoiceService {
  @Asset('reports/invoice') // 3. declarative field, resolved on each access
  private invoiceTemplate!: string
}

resolveAsset('mails', 'fr/welcome') // 4. dynamic — names known only at runtime; throws UnknownAssetError on a miss
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;in-the-build&quot;&gt;In the build&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;kick build&lt;/code&gt; copies every &lt;code&gt;assetMap&lt;/code&gt; entry into the output directory, writes a manifest (&lt;code&gt;.kickjs-assets.json&lt;/code&gt;) and runs typegen. At runtime the resolver uses that manifest when it exists, and walks your &lt;code&gt;src&lt;/code&gt; directories when it doesn't — so the same &lt;code&gt;assets.mails.en.welcome()&lt;/code&gt; call works in &lt;code&gt;kick dev&lt;/code&gt; and in production. After adding a template in dev, &lt;code&gt;kick build:assets&lt;/code&gt; refreshes the manifest without a full rebuild.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-how-they-fit-together&quot;&gt;4. How they fit together&lt;/h3&gt;
&lt;p&gt;The three features form a natural pipeline for any request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;route matched
   │
   ├─ route flags resolved (method &amp;gt; class &amp;gt; mount)          &quot;what is true about this route&quot;
   │
   ├─ contributors run, in dependsOn order                   &quot;what we know about this request&quot;
   │     • skipWhen / onlyWhen read the flags
   │     • resolve() can read ctx.route.flags values
   │     • guards read flags via exemptWhen
   │
   └─ handler / services
         • ctx.require(...) / getRequestValue(...)
         • assets.* picks the file, often chosen by a contributor value
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The division of labour that keeps codebases clean:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Flags describe&lt;/strong&gt; — &quot;public&quot;, &quot;needs the reports feature&quot;, &quot;limit 10 rpm&quot;. Declarative, inheritable, visible in the route list.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Contributors decide&lt;/strong&gt; — load the user, load the plan, check the feature, compute the locale. Typed, ordered, testable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Assets deliver&lt;/strong&gt; — the template or schema the decision points at, with a path that is always right.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The recipes below build that pipeline piece by piece.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-recipe-one-public-flag-every-consumer&quot;&gt;5. Recipe: one &quot;public&quot; flag, every consumer&lt;/h3&gt;
&lt;p&gt;Declare the fact once:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/flags.ts
export const Public = defineRouteFlag('auth.public')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put it on routes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Public
@Controller()
export class AuthController {
  @Post('/login') login(ctx: RequestContext) {}
  @Post('/register') register(ctx: RequestContext) {}

  @Public.off // the only private route here
  @Get('/me')
  me(ctx: RequestContext) {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now &lt;strong&gt;every&lt;/strong&gt; consumer reads that one declaration instead of its own path list:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Auth: the user contributor steps aside on public routes (section 6).
skipWhen: 'auth.public'

// CSRF and rate limiting, as route guards:
@Middleware(csrfGuard({ exemptWhen: 'auth.public' }))
@Middleware(rateLimitGuard({ max: 60, exemptWhen: 'auth.public' }))

// OpenAPI: public routes show no lock icon.
SwaggerAdapter({ bearerAuth: true, publicFlag: 'auth.public' })

// The built-in health probes, which you don't own:
bootstrap({ health: { flags: ['auth.public'] } })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rename a route, change the API prefix, add &lt;code&gt;/users/:id&lt;/code&gt; — nothing breaks, because nothing matches on path strings. And &lt;strong&gt;DevTools&lt;/strong&gt; shows each route's resolved flags in its Routes tab, so &quot;why is this endpoint open?&quot; is answered from the route list.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The framework names no flags. &lt;code&gt;auth.public&lt;/code&gt; is a name you chose — pick a namespace per concern (&lt;code&gt;auth.*&lt;/code&gt;, &lt;code&gt;billing.*&lt;/code&gt;, &lt;code&gt;rate.*&lt;/code&gt;) so readers can tell who consumes a flag.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-recipe-the-signed-in-user-skipped-on-public-routes&quot;&gt;6. Recipe: the signed-in user, skipped on public routes&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/contributors/load-user.contributor.ts
import { defineHttpContextDecorator, HttpException } from '@forinda/kickjs'
import { SESSIONS } from '@/auth/sessions'

declare module '@forinda/kickjs' {
  interface ContextMeta {
    user: { id: string; accountId: string; roles: string[] }
  }
}

export const LoadUser = defineHttpContextDecorator({
  key: 'user',
  skipWhen: 'auth.public', // the flag from section 5
  beforeValidation: true, // answer 401 before 422
  deps: { sessions: SESSIONS },
  resolve: async (ctx, { sessions }) =&amp;gt; {
    const user = await sessions.fromToken(ctx.headers.authorization)
    if (!user) throw HttpException.unauthorized()
    return user
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register it &lt;strong&gt;once&lt;/strong&gt;, app-wide:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;bootstrap({ modules, contributors: [LoadUser.registration] })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every route now requires a user, except routes flagged public. Three details matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;skipWhen&lt;/code&gt; makes exemption work across ownership.&lt;/strong&gt; Without flags, the only way to opt a route out of a contributor is to register a permissive twin under the same key — which you can't do for a contributor a plugin shipped. A flag lives on the route, so you can exempt anything.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;beforeValidation: true&lt;/code&gt;&lt;/strong&gt; moves the contributor ahead of body validation. Otherwise an anonymous request with a bad body gets a &lt;code&gt;422&lt;/code&gt; listing your schema. The order becomes &lt;code&gt;beforeValidation contributors → validation → @Middleware() → other contributors → handler&lt;/code&gt;. Read headers and cookies here, never the body — it isn't validated yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A skipped contributor sets no key.&lt;/strong&gt; On routes that can be public, read &lt;code&gt;ctx.get('user')&lt;/code&gt;; use &lt;code&gt;ctx.require('user')&lt;/code&gt; only where the route is guaranteed private.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Services that need the user don't take it as a parameter:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Service()
export class AuditService {
  record(action: string) {
    const user = getRequestValue('user') // typed, undefined on public routes
    this.log.insert({ action, userId: user?.id ?? null })
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-recipe-plan-gated-routes-with-a-flag-value-and-dependson&quot;&gt;7. Recipe: plan-gated routes with a flag value and dependsOn&lt;/h3&gt;
&lt;p&gt;Goal: some routes require a paid feature. The route &lt;strong&gt;states&lt;/strong&gt; which feature; contributors &lt;strong&gt;enforce&lt;/strong&gt; it.&lt;/p&gt;
&lt;p&gt;The flag carries a value:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const Feature = defineRouteFlag&amp;lt;string&amp;gt;('billing.feature')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Load the account's plan after the user:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/contributors/load-plan.contributor.ts
declare module '@forinda/kickjs' {
  interface ContextMeta {
    plan: { name: 'free' | 'pro' | 'enterprise'; features: string[] }
  }
}

export const LoadPlan = defineHttpContextDecorator({
  key: 'plan',
  dependsOn: ['user'], // runs after LoadUser; boot fails if nothing produces 'user'
  skipWhen: 'auth.public',
  deps: { billing: BILLING },
  resolve: async (ctx, { billing }) =&amp;gt; billing.planFor(ctx.require('user').accountId),
  onError: () =&amp;gt; ({ name: 'free', features: [] }), // billing down → treat as free, don't 500
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Enforce the feature &lt;strong&gt;only where a route asks for one&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/contributors/require-feature.contributor.ts
declare module '@forinda/kickjs' {
  interface ContextMeta {
    feature: string
  }
}

export const RequireFeature = defineHttpContextDecorator({
  key: 'feature',
  onlyWhen: 'billing.feature', // routes without the flag skip this entirely
  dependsOn: ['plan'],
  resolve: (ctx) =&amp;gt; {
    const needed = ctx.route?.flags.get('billing.feature') // the flag's value, e.g. 'reports'
    const plan = ctx.require('plan')
    if (!needed || !plan.features.includes(needed)) {
      ctx.problem.forbidden({
        type: 'https://example.com/probs/upgrade-required',
        detail: `This needs the ${needed} feature, which is not on the ${plan.name} plan`,
      })
    }
    return needed
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register the chain app-wide:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;bootstrap({
  modules,
  contributors: [LoadUser.registration, LoadPlan.registration, RequireFeature.registration],
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now gating a route is one line, and it reads like documentation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Controller()
export class ReportsController {
  @Feature('reports')
  @Get('/monthly')
  monthly(ctx: RequestContext) {} // pro and above

  @Get('/summary')
  summary(ctx: RequestContext) {} // everyone signed in
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why this shape works:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Order is declared, not arranged.&lt;/strong&gt; &lt;code&gt;dependsOn&lt;/code&gt; gives &lt;code&gt;user → plan → feature&lt;/code&gt;. Forget &lt;code&gt;LoadUser&lt;/code&gt; and the app refuses to start with &lt;code&gt;MissingContributorError&lt;/code&gt; instead of failing on the first request.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost is paid only where needed.&lt;/strong&gt; &lt;code&gt;onlyWhen&lt;/code&gt; means routes without the flag never evaluate the check; a skipped contributor costs a map lookup.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Errors keep their shape.&lt;/strong&gt; &lt;code&gt;ctx.problem.forbidden(...)&lt;/code&gt; answers RFC 9457 &lt;code&gt;application/problem+json&lt;/code&gt; from inside a contributor, so clients can branch on &lt;code&gt;type&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The fallback is explicit.&lt;/strong&gt; &lt;code&gt;onError&lt;/code&gt; returns a value; the runner stores it under the key.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-recipe-per-route-rate-limits-from-one-declaration&quot;&gt;8. Recipe: per-route rate limits from one declaration&lt;/h3&gt;
&lt;p&gt;A flag with a value, read by several consumers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const RateLimit = defineRouteFlag&amp;lt;{ rpm: number }&amp;gt;('rate.limit')

@RateLimit({ rpm: 10 })
@Post('/login')
login(ctx: RequestContext) {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Inside the route&lt;/strong&gt; — a guard can exempt unmetered routes with a predicate that reads the value:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Middleware(
  rateLimitGuard({
    max: 60,
    exemptWhen: ({ flags }) =&amp;gt; flags.get('rate.limit')?.rpm === 0,
  }),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;A contributor&lt;/strong&gt; can derive the limiter key and window from the same flag:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;declare module '@forinda/kickjs' {
  interface ContextMeta {
    rateLimit: { key: string; rpm: number }
  }
}

export const RateLimitKey = defineHttpContextDecorator({
  key: 'rateLimit',
  onlyWhen: 'rate.limit',
  resolve: (ctx) =&amp;gt; ({
    key: `${ctx.get('user')?.id ?? ctx.req.ip}:${ctx.route?.path}`,
    rpm: ctx.route?.flags.get('rate.limit')?.rpm ?? 60,
  }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Before routing&lt;/strong&gt; — app-wide middleware has no &lt;code&gt;ctx.route&lt;/code&gt;, so it reads a policy table the app builds at boot from every mounted route's method, path and flags:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;bootstrap({ middlewares: [rateLimit({ max: 60, exemptWhen: 'auth.public' })] })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your own pre-routing middleware can read the same table:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { bindRoutePolicy, type RoutePolicyTable } from '@forinda/kickjs'

export function accessLog() {
  let policy: RoutePolicyTable | undefined
  const handler = (req, _res, next) =&amp;gt; {
    const flags = policy?.lookup(req.method, req.url ?? '/')
    if (!flags?.has('audit.skip')) log.info({ method: req.method, url: req.url })
    next()
  }
  return bindRoutePolicy(handler, (table) =&amp;gt; {
    policy = table
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A request that matches no route matches no flags, and stays limited and logged — which is exactly what you want for scanners probing random paths.&lt;/p&gt;
&lt;h4 id=&quot;the-three-shapes-a-flag-test-can-take&quot;&gt;The three shapes a flag test can take&lt;/h4&gt;
&lt;p&gt;Every &lt;code&gt;skipWhen&lt;/code&gt;, &lt;code&gt;onlyWhen&lt;/code&gt; and &lt;code&gt;exemptWhen&lt;/code&gt; accepts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;'auth.public' // carries this flag
'!auth.public' // does NOT carry it
['auth.public', 'health.probe'] // carries ANY of these
['!auth.public', '!health.probe'] // carries NONE of these
({ flags, route }) =&amp;gt; flags.has('a') &amp;amp;&amp;amp; flags.has('b') // anything else
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A list is single-polarity: &lt;code&gt;['auth.public', '!metered']&lt;/code&gt; is a compile error, because &quot;any-of&quot; would read it as &quot;public &lt;strong&gt;or&lt;/strong&gt; not metered&quot; while most people read &quot;and&quot;. Use a predicate for mixed conditions, and keep predicates cheap — they run per request.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-recipe-flag-or-parameter-choosing-the-right-tool&quot;&gt;9. Recipe: flag or parameter? Choosing the right tool&lt;/h3&gt;
&lt;p&gt;Contributors can also take &lt;strong&gt;per-call parameters&lt;/strong&gt;, which overlaps with flags. The rule of thumb:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use a &lt;strong&gt;flag&lt;/strong&gt; when the fact is read by &lt;strong&gt;several consumers&lt;/strong&gt;, or by tooling (OpenAPI, DevTools), or must be set on routes you don't own.&lt;/li&gt;
&lt;li&gt;Use a &lt;strong&gt;parameter&lt;/strong&gt; when it only configures &lt;strong&gt;one contributor's behaviour&lt;/strong&gt; at that call site.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A permission check is a good parameter — only one contributor cares, and the action must be named at every use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;declare module '@forinda/kickjs' {
  interface ContextMeta {
    permission: string
  }
}

export const RequirePermission = defineHttpContextDecorator.withParams&amp;lt;{ action: string }&amp;gt;()({
  key: 'permission',
  dependsOn: ['user'],
  // no paramDefaults.action — every route must name its own action
  resolve: (ctx, _deps, { action }) =&amp;gt; {
    if (!ctx.require('user').roles.includes(action)) throw HttpException.forbidden()
    return action
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@RequirePermission({ action: 'reports:export' }) // ✓
@Get('/export')
export(ctx: RequestContext) {}

@RequirePermission // ✗ compile error: `action` is required
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Required parameters have no default on purpose: a placeholder default that one route forgets to override silently gates on the placeholder. For module, adapter or bootstrap registration, use &lt;code&gt;RequirePermission.with({ action: '…' }).registration&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Compare with &lt;code&gt;billing.feature&lt;/code&gt; from section 7: that one is a &lt;strong&gt;flag&lt;/strong&gt; because the billing contributor, the OpenAPI spec (to document &quot;requires Pro&quot;), and DevTools all benefit from reading it.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Need &lt;/th&gt;&lt;th&gt;Reach for&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;This route is public / metered / exempt&quot; &lt;/td&gt;&lt;td&gt;Flag&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Requires feature X&quot; (and docs should say so) &lt;/td&gt;&lt;td&gt;Flag with a value&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Check permission &lt;code&gt;reports:export&lt;/code&gt;&quot; &lt;/td&gt;&lt;td&gt;Parameterised contributor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Load the tenant from a header here, a subdomain there&quot; &lt;/td&gt;&lt;td&gt;Parameterised contributor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Mark routes on a controller from another package &lt;/td&gt;&lt;td&gt;Flag on the module mount&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;10-recipe-locale-aware-emails-from-typed-templates&quot;&gt;10. Recipe: locale-aware emails from typed templates&lt;/h3&gt;
&lt;p&gt;Now bring in assets. Keep templates per language:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;src/templates/mails/en/welcome.ejs
src/templates/mails/en/password-reset.ejs
src/templates/mails/fr/welcome.ejs
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// kick.config.ts
assetMap: {
  mails: { src: 'src/templates/mails', glob: '**/*.ejs' },
},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After &lt;code&gt;kick typegen&lt;/code&gt;, &lt;code&gt;assets.mails.en.welcome()&lt;/code&gt; and &lt;code&gt;assets.mails['en']['password-reset']()&lt;/code&gt; are typed.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;locale contributor&lt;/strong&gt; (section 2) decides the language per request. The &lt;strong&gt;mail service&lt;/strong&gt; picks the matching template, falling back to English when a translation is missing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/mail/mail.service.ts
import { Service, assets, getRequestValue, resolveAsset, UnknownAssetError } from '@forinda/kickjs'
import ejs from 'ejs'

type MailName = 'welcome' | 'password-reset'

@Service()
export class MailService {
  async render(name: MailName, data: Record&amp;lt;string, unknown&amp;gt;) {
    const language = getRequestValue('locale')?.language ?? 'en'
    return ejs.renderFile(this.templateFor(name, language), data)
  }

  private templateFor(name: MailName, language: string): string {
    try {
      return resolveAsset('mails', `${language}/${name}`) // dynamic: language is runtime data
    } catch (error) {
      if (error instanceof UnknownAssetError) return assets.mails.en[name]() // typed fallback
      throw error
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Look at who does what:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The contributor&lt;/strong&gt; turns a header into a typed &lt;code&gt;locale&lt;/code&gt; once per request.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The service&lt;/strong&gt; reads it with &lt;code&gt;getRequestValue&lt;/code&gt; — no &lt;code&gt;ctx&lt;/code&gt; threaded through three layers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The asset manager&lt;/strong&gt; turns &lt;code&gt;fr/welcome&lt;/code&gt; into a real path in dev and in the build, and the typed &lt;code&gt;assets.mails.en[name]()&lt;/code&gt; fallback means English templates are checked at compile time.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Outside a request (a queue worker, a cron job) &lt;code&gt;getRequestValue&lt;/code&gt; returns &lt;code&gt;undefined&lt;/code&gt;, and the service falls back to English instead of throwing.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;11-recipe-reports-rendered-per-route&quot;&gt;11. Recipe: reports rendered per route&lt;/h3&gt;
&lt;p&gt;Report templates are a good fit for the &lt;code&gt;@Asset&lt;/code&gt; field decorator — fixed files, injected into a service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// kick.config.ts
assetMap: {
  reports: { src: 'src/templates/reports', glob: '**/*.ejs' },
},
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Service()
export class ReportService {
  @Asset('reports/monthly')
  private monthlyTemplate!: string

  @Asset('reports/invoice')
  private invoiceTemplate!: string

  monthly(data: MonthlyReport) {
    return ejs.renderFile(this.monthlyTemplate, data)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Combined with section 7, the controller carries all the policy as declarations:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Controller()
export class ReportsController {
  @Autowired() private readonly reports!: ReportService

  @Feature('reports') // flag: needs the reports feature
  @RateLimit({ rpm: 5 }) // flag: expensive, limit it
  @ResolveLocale // contributor: formats numbers and dates for the reader
  @Get('/monthly')
  async monthly(ctx: RequestContext) {
    const html = await this.reports.monthly({
      user: ctx.require('user'),
      plan: ctx.require('plan'),
      locale: ctx.require('locale'),
    })
    return ctx.html(html)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reading the decorators top to bottom tells you everything about the endpoint without opening a single middleware file: who can call it, how often, and what context it renders with.&lt;/p&gt;
&lt;p&gt;JSON schemas and fixtures work the same way — &lt;code&gt;assets.schemas.webhook()&lt;/code&gt; gives a validator the right file in every environment.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;12-recipe-flagging-controllers-you-don-t-own&quot;&gt;12. Recipe: flagging controllers you don't own&lt;/h3&gt;
&lt;p&gt;Decorators only work on code you can edit. A module can flag routes on &lt;strong&gt;any&lt;/strong&gt; controller it mounts — including one from a package:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/modules/webhooks/webhooks.module.ts
import { defineModule } from '@forinda/kickjs'
import { StripeWebhookController } from 'some-payments-package'

export const WebhooksModule = defineModule({
  name: 'WebhooksModule',
  build: () =&amp;gt; ({
    routes: () =&amp;gt; ({
      path: '/webhooks',
      controller: StripeWebhookController,
      flags: { 'auth.public': true, 'rate.limit': { rpm: 600 } },
    }),
  }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mount flags are the lowest precedence, so a class or method declaration of the same flag still wins. A list form (&lt;code&gt;flags: ['auth.public']&lt;/code&gt;) stores each flag as &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The same idea covers the framework's own health probes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;bootstrap({ health: { flags: ['auth.public'] } })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every consumer already reading &lt;code&gt;auth.public&lt;/code&gt; — &lt;code&gt;LoadUser&lt;/code&gt;, the guards, the OpenAPI spec — now exempts &lt;code&gt;/health/live&lt;/code&gt; and &lt;code&gt;/health/ready&lt;/code&gt;, with no path strings.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;13-type-safety-what-the-compiler-catches&quot;&gt;13. Type safety: what the compiler catches&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;kick typegen&lt;/code&gt; (automatic in &lt;code&gt;kick dev&lt;/code&gt;) generates three registries into &lt;code&gt;.kickjs/types/&lt;/code&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;You write &lt;/th&gt;&lt;th&gt;Typegen generates &lt;/th&gt;&lt;th&gt;The compiler then catches&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;defineRouteFlag('rate.limit')&lt;/code&gt; with a value type &lt;/td&gt;&lt;td&gt;&lt;code&gt;KickRouteFlags&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;skipWhen: 'auth.pubic'&lt;/code&gt; → &lt;em&gt;Did you mean 'auth.public'?&lt;/em&gt;; &lt;code&gt;flags.get('rate.limit')?.rpm&lt;/code&gt; typed, not &lt;code&gt;unknown&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;declare module … ContextMeta&lt;/code&gt; &lt;/td&gt;&lt;td&gt;— (you write it) &lt;/td&gt;&lt;td&gt;&lt;code&gt;ctx.require('locale')&lt;/code&gt; typed; &lt;code&gt;dependsOn: ['usr']&lt;/code&gt; is an error&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;assetMap&lt;/code&gt; in &lt;code&gt;kick.config.ts&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;KickAssets&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;assets.mails.en.welcom()&lt;/code&gt; is an error&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;And one more that is easy to miss: &lt;strong&gt;a deleted decorator becomes a compile error.&lt;/strong&gt; When a handler is typed with a generated route type, typegen narrows &lt;code&gt;ctx.require()&lt;/code&gt; to the keys that route provably has:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@LoadTenant
@RequirePermission({ action: 'audit:read' }) // ← delete this line…
@Get('/audit')
audit(ctx: Ctx&amp;lt;KickRoutes.AuditController['audit']&amp;gt;) {
  return { perm: ctx.require('permission') } // …and this no longer compiles
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Typegen narrows only when it can prove the whole contributor set. Adapter or plugin &lt;code&gt;contributors()&lt;/code&gt; can't be read statically, so projects using them skip the narrowing — &lt;code&gt;ctx.require()&lt;/code&gt; still throws at runtime, which is why integration tests stay worth having.&lt;/p&gt;
&lt;p&gt;All three registries are opt-in: until you declare your first flag, &lt;code&gt;ContextMeta&lt;/code&gt; key or &lt;code&gt;assetMap&lt;/code&gt; entry, everything accepts plain strings and keeps compiling.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;14-testing-all-three&quot;&gt;14. Testing all three&lt;/h3&gt;
&lt;h4 id=&quot;contributors-in-isolation&quot;&gt;Contributors, in isolation&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;runContributor&lt;/code&gt; from &lt;code&gt;@forinda/kickjs-testing&lt;/code&gt; calls a contributor's &lt;code&gt;resolve()&lt;/code&gt; against a fake context — no container, no HTTP. Hand it mocked &lt;code&gt;deps&lt;/code&gt; and pre-seed upstream keys with &lt;code&gt;initial&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { describe, expect, it, vi } from 'vitest'
import { runContributor } from '@forinda/kickjs-testing'
import { LoadPlan } from './load-plan.contributor'

describe('LoadPlan', () =&amp;gt; {
  it('loads the plan for the signed-in account', async () =&amp;gt; {
    const billing = { planFor: vi.fn(async () =&amp;gt; ({ name: 'pro', features: ['reports'] })) }

    const { value } = await runContributor(LoadPlan, {
      deps: { billing },
      initial: { user: { id: 'u1', accountId: 'acc-9', roles: [] } }, // what LoadUser would have set
    })

    expect(billing.planFor).toHaveBeenCalledWith('acc-9')
    expect(value).toEqual({ name: 'pro', features: ['reports'] })
  })
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;runContributor&lt;/code&gt; calls &lt;code&gt;resolve()&lt;/code&gt; directly, so &lt;code&gt;optional&lt;/code&gt; and &lt;code&gt;onError&lt;/code&gt; paths need the pipeline: build one with &lt;code&gt;buildPipeline([...])&lt;/code&gt; and run it with &lt;code&gt;runContributors(...)&lt;/code&gt; from &lt;code&gt;@forinda/kickjs&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;flags-without-a-request&quot;&gt;Flags, without a request&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;getRouteFlags(controllerClass, handlerName)&lt;/code&gt; returns the same resolved flags a live request sees — method over class over mount:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { getRouteFlags } from '@forinda/kickjs'

it('keeps /me private while the rest of AuthController is public', () =&amp;gt; {
  expect(getRouteFlags(AuthController, 'login').has('auth.public')).toBe(true)
  expect(getRouteFlags(AuthController, 'me').has('auth.public')).toBe(false)
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A test like this is cheap insurance on the one flag whose removal is a security bug.&lt;/p&gt;
&lt;h4 id=&quot;the-whole-pipeline&quot;&gt;The whole pipeline&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;createTestApp&lt;/code&gt; from &lt;code&gt;@forinda/kickjs-testing&lt;/code&gt; boots the app with real contributors, flags and ordering; drive it with Supertest and assert the outcomes: &lt;code&gt;401&lt;/code&gt; without a token, &lt;code&gt;200&lt;/code&gt; on a public route, &lt;code&gt;403&lt;/code&gt; with the upgrade problem type on a gated route for a free plan. &lt;code&gt;createTestApp&lt;/code&gt; also sets up the request scope, so services calling &lt;code&gt;getRequestValue&lt;/code&gt; work in these tests.&lt;/p&gt;
&lt;h4 id=&quot;assets-with-fixtures&quot;&gt;Assets, with fixtures&lt;/h4&gt;
&lt;p&gt;Point the resolver at a fixture manifest and clear its cache:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { join } from 'node:path'
import { clearAssetCache } from '@forinda/kickjs'

beforeEach(() =&amp;gt; {
  process.env.KICK_ASSETS_ROOT = join(__dirname, 'fixtures/assets') // contains .kickjs-assets.json
  clearAssetCache()
})

afterEach(() =&amp;gt; {
  delete process.env.KICK_ASSETS_ROOT
  clearAssetCache()
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@Asset&lt;/code&gt; fields resolve on every access, so existing service instances pick up the fixtures without being recreated.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;15-pitfalls&quot;&gt;15. Pitfalls&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Symptom &lt;/th&gt;&lt;th&gt;Cause &lt;/th&gt;&lt;th&gt;Fix&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;A &quot;private&quot; route is treated as public &lt;/td&gt;&lt;td&gt;&lt;code&gt;@Public(false)&lt;/code&gt; style thinking &lt;/td&gt;&lt;td&gt;Remove with &lt;code&gt;@Public.off&lt;/code&gt;; flags have no false state&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;@Public.off&lt;/code&gt; does nothing &lt;/td&gt;&lt;td&gt;The route never inherited the flag &lt;/td&gt;&lt;td&gt;Check where you expected it to come from&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Handler sees &lt;code&gt;undefined&lt;/code&gt; for a contributor value &lt;/td&gt;&lt;td&gt;Assigned &lt;code&gt;ctx.x = …&lt;/code&gt; in &lt;code&gt;resolve&lt;/code&gt;/&lt;code&gt;onError&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Return&lt;/strong&gt; the value; the runner stores it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ctx.require('user')&lt;/code&gt; throws on some routes &lt;/td&gt;&lt;td&gt;The contributor was skipped by &lt;code&gt;skipWhen&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Use &lt;code&gt;ctx.get('user')&lt;/code&gt; on routes that can be public&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Anonymous callers see validation errors &lt;/td&gt;&lt;td&gt;Auth contributor runs after validation &lt;/td&gt;&lt;td&gt;&lt;code&gt;beforeValidation: true&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;MissingContributorError&lt;/code&gt; at boot &lt;/td&gt;&lt;td&gt;A &lt;code&gt;dependsOn&lt;/code&gt; key has no producer for that route &lt;/td&gt;&lt;td&gt;Register the producer at the same or broader site&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;beforeValidation&lt;/code&gt; contributor fails boot &lt;/td&gt;&lt;td&gt;It depends on a normal contributor &lt;/td&gt;&lt;td&gt;Early contributors may only depend on early ones&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Flag test typo matches nothing &lt;/td&gt;&lt;td&gt;Flags not declared yet &lt;/td&gt;&lt;td&gt;Declare with &lt;code&gt;defineRouteFlag&lt;/code&gt;; typegen narrows every name&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;['a', '!b']&lt;/code&gt; won't compile &lt;/td&gt;&lt;td&gt;Lists are single-polarity &lt;/td&gt;&lt;td&gt;Use a predicate&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Asset works in dev, missing in production &lt;/td&gt;&lt;td&gt;File not matched by &lt;code&gt;glob&lt;/code&gt;, or custom &lt;code&gt;build.outDir&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Widen the glob; set &lt;code&gt;build.outDir&lt;/code&gt; in &lt;code&gt;kick.config.ts&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Two files, one asset key &lt;/td&gt;&lt;td&gt;Same basename (&lt;code&gt;index.html&lt;/code&gt; + &lt;code&gt;index.pug&lt;/code&gt;) &lt;/td&gt;&lt;td&gt;Default &lt;code&gt;keys: 'auto'&lt;/code&gt; keeps extensions for that group&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;New template not found in dev &lt;/td&gt;&lt;td&gt;Manifest is stale &lt;/td&gt;&lt;td&gt;&lt;code&gt;kick build:assets&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;welcome-email.ejs&lt;/code&gt; needs bracket access &lt;/td&gt;&lt;td&gt;Non-identifier file name &lt;/td&gt;&lt;td&gt;&lt;code&gt;assets.mails['welcome-email']()&lt;/code&gt;, or rename the file&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-cli-commands-used&quot;&gt;The CLI commands used&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Command &lt;/th&gt;&lt;th&gt;What it does here&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick g contributor &amp;lt;name&amp;gt;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Scaffolds a context decorator with its &lt;code&gt;ContextMeta&lt;/code&gt; stub (&lt;code&gt;--type bare&lt;/code&gt; for transport-agnostic, &lt;code&gt;--params &quot;action:string&quot;&lt;/code&gt; for the parameterised form, &lt;code&gt;-m &amp;lt;module&amp;gt;&lt;/code&gt; to place it in a module).&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick typegen&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Generates &lt;code&gt;KickRouteFlags&lt;/code&gt;, route types for &lt;code&gt;ctx.require()&lt;/code&gt; narrowing, and &lt;code&gt;KickAssets&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick dev&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Runs the app with hot reload and typegen on every change.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick build&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Builds, copies &lt;code&gt;assetMap&lt;/code&gt; files, writes the asset manifest and runs typegen.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick build:assets&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Refreshes the asset manifest without a JS rebuild.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--dry-run&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Previews any generator's files first.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;recap&quot;&gt;Recap&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Route flags&lt;/strong&gt; are facts on routes: declared once, inherited method &amp;gt; class &amp;gt; mount, removed with &lt;code&gt;.off&lt;/code&gt;, and read by contributors, guards, pre-routing middleware, OpenAPI and DevTools.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context decorators&lt;/strong&gt; are typed per-request values: ordered with &lt;code&gt;dependsOn&lt;/code&gt;, wired to services with &lt;code&gt;deps&lt;/code&gt;, skipped per route with &lt;code&gt;skipWhen&lt;/code&gt; / &lt;code&gt;onlyWhen&lt;/code&gt;, run early with &lt;code&gt;beforeValidation&lt;/code&gt;, parameterised with &lt;code&gt;.withParams&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The asset manager&lt;/strong&gt; gives typed paths to shipped files: &lt;code&gt;assetMap&lt;/code&gt; in config, &lt;code&gt;assets.*&lt;/code&gt; in code, correct in dev and in the build.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Together:&lt;/strong&gt; flags describe the route, contributors decide about the request, assets deliver the file the decision points at — and typegen checks all three.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;links&quot;&gt;Links&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;KickJS on GitHub:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/forinda/kick-js&quot;&gt;github.com/forinda/kick-js&lt;/a&gt; — source, issues and discussions. A ⭐ helps others find it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Guides:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/route-flags.html&quot;&gt;Route flags&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/context-decorators.html&quot;&gt;Context decorators&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/asset-manager.html&quot;&gt;Asset manager&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docs:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/&quot;&gt;kickjs.app&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28152/route-flags-context-decorators-and-typed-assets-in-kickjs-declare-it-once-use-everywhere</guid>
<pubDate>Tue, 22 Sep 2026 11:40:21 +0000</pubDate>
</item>
<item>
<title>I migrated a 593-route Express app to KickJS twice — here's what held up</title>
<link>https://coderlegion.com/28151/i-migrated-a-593-route-express-app-to-kickjs-twice-heres-what-held-up</link>
<description>&lt;p&gt;I spent a while moving a large multi-tenant SaaS platform off Express 4 +&lt;br&gt;
Sequelize. The numbers going in:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;54 apps, ~593 routes&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;241 tables&lt;/strong&gt; across 434 migrations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;database-per-tenant&lt;/strong&gt; — one Postgres database per customer organisation&lt;/li&gt;
&lt;li&gt;one 3,046-line controller, and a route table with 26 endpoints that had never&lt;br&gt;
once executed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I did it twice. First as a strangler — KickJS mounted &lt;em&gt;inside&lt;/em&gt; the running&lt;br&gt;
Express app, serving &lt;code&gt;/api/v2&lt;/code&gt; beside the legacy &lt;code&gt;/api/v1&lt;/code&gt;, proven route by route&lt;br&gt;
against the original. Then, once that had taught me what the system actually&lt;br&gt;
does, as a clean rewrite on a fresh KickJS host with Drizzle.&lt;/p&gt;
&lt;p&gt;Both runs used the same framework, and that is the thing I want to write about.&lt;br&gt;
Most frameworks are good at one of those two jobs.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Versions throughout: &lt;code&gt;@forinda/kickjs@8.3.1&lt;/code&gt;, &lt;code&gt;@forinda/kickjs-cli@8.1.5&lt;/code&gt;,&lt;br&gt;
Express 5.1, zod 4, Drizzle 0.45. Code examples use a generic&lt;br&gt;
catalog/orders domain.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-feature-that-made-the-strangler-possible&quot;&gt;The feature that made the strangler possible&lt;/h3&gt;
&lt;p&gt;A KickJS module's &lt;code&gt;routes()&lt;/code&gt; can return a controller &lt;strong&gt;or&lt;/strong&gt; a raw Express router,&lt;br&gt;
and you can mount both under the same path:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;routes() {
  return [
    { path: '/catalog', controller: CatalogController }, // the routes I'd converted
    { path: '/catalog', router: legacyCatalogRouter },   // the ones I hadn't
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Express takes the first match, so converted routes win and the legacy router&lt;br&gt;
serves the rest. &lt;strong&gt;No unconverted route registration has to be copied&lt;/strong&gt;, and a&lt;br&gt;
route moves across one at a time by adding it to the controller and deleting&lt;br&gt;
nothing.&lt;/p&gt;
&lt;p&gt;That is what let a 593-route app migrate incrementally instead of in a big bang.&lt;br&gt;
I haven't found another structured Node framework where &quot;here is an Express&lt;br&gt;
Router, serve it beside my controllers&quot; is a first-class mount.&lt;/p&gt;
&lt;p&gt;The inverse also worked, and is undocumented: &lt;code&gt;new Application()&lt;/code&gt; + &lt;code&gt;setup()&lt;/code&gt;&lt;br&gt;
gives you the pipeline without a listener, and &lt;code&gt;handle()&lt;/code&gt; takes raw Node&lt;br&gt;
&lt;code&gt;IncomingMessage&lt;/code&gt;/&lt;code&gt;ServerResponse&lt;/code&gt; — which is how a KickJS app ran &lt;em&gt;inside&lt;/em&gt; an&lt;br&gt;
Express 4 host despite the framework declaring an Express 5 peer.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;context-contributors-and-how-to-write-one&quot;&gt;Context contributors — and how to write one&lt;/h3&gt;
&lt;p&gt;This is the part I'd steal for any other project. A contributor computes one&lt;br&gt;
typed value per request and publishes it on the context under a key. Here is a&lt;br&gt;
complete one, in the three pieces it always has.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Declare the key and its type&lt;/strong&gt;, by augmenting the framework's registry.&lt;br&gt;
This is what makes &lt;code&gt;ctx.require('actor')&lt;/code&gt; typed everywhere downstream:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { defineHttpContextDecorator, HttpException } from '@forinda/kickjs'

export interface Actor {
  readonly userId: string
  readonly organizationId: string
  readonly isAdmin: boolean
}

declare module '@forinda/kickjs' {
  interface ContextMeta {
    actor: Actor
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2. Define the resolver.&lt;/strong&gt; &lt;code&gt;deps&lt;/code&gt; pulls services out of the DI container,&lt;br&gt;
&lt;code&gt;dependsOn&lt;/code&gt; declares which other contributors must run first, and &lt;code&gt;skipWhen&lt;/code&gt;&lt;br&gt;
names the route flags that switch it off entirely:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const Actor = defineHttpContextDecorator({
  key: 'actor',
  skipWhen: ['auth.public'],
  dependsOn: ['tenantDb'],
  deps: { repo: AUTH_REPOSITORY },
  async resolve(ctx, { repo }): Promise&amp;lt;Actor&amp;gt; {
    const token = bearerToken(ctx.req.headers.authorization)
    if (!token) throw new HttpException(401, 'Authentication required')

    const claims = await verifyAccessToken(token)
    if (!claims) throw new HttpException(401, 'Authentication required')

    return repo.loadActor(claims.sub)
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Throwing from &lt;code&gt;resolve&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the rejection — there's no separate guard step.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Register it&lt;/strong&gt;, either globally in &lt;code&gt;bootstrap({ contributors })&lt;/code&gt;, on a module&lt;br&gt;
when every route in it shares the value, or per handler as a decorator:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;contributors() {
  return [Actor.registration, TenantDb.registration]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things earn their keep:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The dependency is declared, so it fails at boot.&lt;/strong&gt; The legacy app had two&lt;br&gt;
different producers of the same tenant-database key — one from an &lt;code&gt;x-tenant-id&lt;/code&gt;&lt;br&gt;
header, one from the bearer token. Declaring &lt;code&gt;dependsOn&lt;/code&gt; caught a real bug on the&lt;br&gt;
first run: two routes carried the token-based resolver with no authenticated&lt;br&gt;
user, which would have handed the service &lt;code&gt;undefined&lt;/code&gt; at runtime. Missing&lt;br&gt;
dependencies and cycles fail at startup, not per request.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Precedence is method &amp;gt; class &amp;gt; module &amp;gt; adapter &amp;gt; bootstrap.&lt;/strong&gt; Register once on&lt;br&gt;
the module when every route shares a value; override per handler when one&lt;br&gt;
doesn't. In the rewrite the whole tenant story is a chain the framework&lt;br&gt;
topo-sorts for you — order in the array is irrelevant:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;tenantRoute → tenantDb → actor → permissions
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Compare with the Express version, which resolved the tenant in the auth&lt;br&gt;
middleware by &lt;em&gt;copying&lt;/em&gt; the tenant middleware's logic inline, then wrote the&lt;br&gt;
result to four differently-named request properties. Both are &quot;middleware&quot;. Only&lt;br&gt;
one of them tells you when you've wired it wrong.&lt;/p&gt;
&lt;h4 id=&quot;parameterised-decorators&quot;&gt;Parameterised decorators&lt;/h4&gt;
&lt;p&gt;The same primitive builds a decorator that takes arguments. &lt;code&gt;withParams&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;requiredParams&lt;/code&gt; are the whole trick:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;declare module '@forinda/kickjs' {
  interface ContextKeys {
    // Key-only: the value is the decision, and nothing downstream reads it.
    permissionGranted: true
  }
}

export const RequirePermission = defineHttpContextDecorator.withParams&amp;lt;{
  action: ActionName
  module: ModuleName
}&amp;gt;()({
  key: 'permissionGranted',
  dependsOn: ['permissions'],
  // Makes a bare `@RequirePermission` a boot error rather than a route that
  // silently checks nothing.
  requiredParams: ['action', 'module'],
  resolve(ctx, _deps, params): true {
    const decision = can(ctx.get('permissions'), params.action, params.module)
    if (!decision.allowed) {
      throw new HttpException(403, `Forbidden: ${decision.reason}`)
    }
    return true
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Used at the point it applies to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@RequirePermission({ action: 'create', module: 'orders' })
@Post('/')
create(ctx: Ctx&amp;lt;KickRoutes.OrderController['create']&amp;gt;) { … }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the &lt;code&gt;ContextMeta&lt;/code&gt; / &lt;code&gt;ContextKeys&lt;/code&gt; split: &lt;code&gt;ContextMeta&lt;/code&gt; when something&lt;br&gt;
downstream reads the value, &lt;code&gt;ContextKeys&lt;/code&gt; when the decorator exists only for its&lt;br&gt;
side effect. Both give you compile-time checking on &lt;code&gt;dependsOn&lt;/code&gt; strings.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;route-flags-and-inverting-the-default&quot;&gt;Route flags, and inverting the default&lt;/h3&gt;
&lt;p&gt;The single highest-value change in the rewrite was three lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;skipWhen: ['auth.public', 'tenant.controlPlane']
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Authentication now &lt;strong&gt;rejects by default&lt;/strong&gt;, and a route opts out explicitly. The&lt;br&gt;
legacy app worked the other way round: you added &lt;code&gt;preAuthorize()&lt;/code&gt; to each route&lt;br&gt;
that needed it.&lt;/p&gt;
&lt;p&gt;That difference is not theoretical. Porting turned up whole modules where someone&lt;br&gt;
had simply forgotten — every route open, nobody having decided they should be.&lt;br&gt;
Forgetting to add a guard is silent. Forgetting to add &lt;code&gt;auth.public&lt;/code&gt; is a 401 on&lt;br&gt;
your first request.&lt;/p&gt;
&lt;p&gt;Declaring a flag is one line, and it's worth putting them all in one file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { defineRouteFlag } from '@forinda/kickjs'

/** Reachable without a token — login, password reset, health. */
export const Public = defineRouteFlag('auth.public')

/** Deliberately not tenant-scoped — operator console, provisioning. */
export const ControlPlane = defineRouteFlag('tenant.controlPlane')
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Public
@Post('/login')
login(ctx: Ctx&amp;lt;…&amp;gt;) { … }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;kick typegen&lt;/code&gt; collects every &lt;code&gt;defineRouteFlag&lt;/code&gt; call into a &lt;code&gt;KickRouteFlags&lt;/code&gt;&lt;br&gt;
registry, so a typo in a &lt;code&gt;skipWhen&lt;/code&gt; string is a compile error rather than a&lt;br&gt;
contributor that silently never skips. Flags resolve method &amp;gt; class &amp;gt; mount, same&lt;br&gt;
as contributors, and &lt;code&gt;@Flag.off&lt;/code&gt; removes an inherited one.&lt;/p&gt;
&lt;p&gt;A flag carries no behaviour of its own — it records a fact that any consumer can&lt;br&gt;
read. That's what makes them composable: the same &lt;code&gt;auth.public&lt;/code&gt; is read by the&lt;br&gt;
actor contributor, the audit log and the rate limiter, none of which know about&lt;br&gt;
each other.&lt;/p&gt;
&lt;h4 id=&quot;pre-match-middleware-can-read-them-too&quot;&gt;Pre-match middleware can read them too&lt;/h4&gt;
&lt;p&gt;I initially assumed flags were unavailable to connect-style middleware, since&lt;br&gt;
that runs before the router matches and there's no route yet. Wrong — there's an&lt;br&gt;
explicit hand-off:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { bindRoutePolicy, type RoutePolicyTable } from '@forinda/kickjs'

export function auditUnflagged() {
  let policy: RoutePolicyTable | undefined
  const handler = (req, _res, next) =&amp;gt; {
    const flags = policy?.lookup(req.method, req.url ?? '/')
    if (!flags?.has('audit.skip')) log(req.url)
    next()
  }
  return bindRoutePolicy(handler, (table) =&amp;gt; { policy = table })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;lookup()&lt;/code&gt; returns an empty map when the path matches &lt;strong&gt;no route at all&lt;/strong&gt; — which&lt;br&gt;
is precisely the traffic you want a rate limiter or an audit log to still see.&lt;br&gt;
The table is handed per-application rather than through a global, so two apps in&lt;br&gt;
one process never see each other's routes.&lt;/p&gt;
&lt;p&gt;The split is deliberate, and the pairing is instructive: &lt;code&gt;rateLimit()&lt;/code&gt; runs&lt;br&gt;
pre-match because an unmatched surface still needs protecting; &lt;code&gt;csrfGuard()&lt;/code&gt; is a&lt;br&gt;
guard because a token check on a route that doesn't exist is meaningless. Having&lt;br&gt;
both forms — and being pointed at the right one by which of them exposes the&lt;br&gt;
table — beats picking one and living with it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;typed-end-to-end-without-ceremony&quot;&gt;Typed end to end without ceremony&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;kick typegen&lt;/code&gt; walks the modules and generates a &lt;code&gt;KickRoutes&lt;/code&gt; namespace. In the&lt;br&gt;
handler you get params, body, query and response typed with no codegen artifacts&lt;br&gt;
in your source:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Post('/', { body: createOrderSchema, name: 'CreateOrder' })
@ApiTags('Orders')
@RequirePermission({ action: 'create', module: 'orders' })
async create(ctx: Ctx&amp;lt;KickRoutes.OrderController['create']&amp;gt;) {
  return reply.created(await this.orders.create(ctx.body))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One zod schema gives you runtime validation &lt;strong&gt;and&lt;/strong&gt; the OpenAPI request body.&lt;br&gt;
&lt;code&gt;ctx.body&lt;/code&gt; is inferred from it. The return type flows into&lt;br&gt;
&lt;code&gt;KickRoutes[...].response&lt;/code&gt;, which is what makes a generated client know what each&lt;br&gt;
route hands back.&lt;/p&gt;
&lt;p&gt;The rewrite currently registers &lt;strong&gt;459 routes and 295 services&lt;/strong&gt; this way.&lt;/p&gt;
&lt;p&gt;A related nicety: handlers return values rather than writing to the response.&lt;br&gt;
&lt;code&gt;reply(status, body)&lt;/code&gt; when the status isn't 200, &lt;code&gt;ctx.problem.notFound(...)&lt;/code&gt; for&lt;br&gt;
error branches — the latter sends immediately and returns nothing, so failure&lt;br&gt;
cases stay out of the inferred success type.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;adapters-as-the-composition-seam&quot;&gt;Adapters as the composition seam&lt;/h3&gt;
&lt;p&gt;An adapter bundles everything one concern needs — DI registrations, middleware,&lt;br&gt;
contributors, lifecycle hooks, a health probe:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const TenantAdapter = defineAdapter({
  name: 'TenantAdapter',
  build: (config) =&amp;gt; ({
    middleware() { /* connect-style, phase-scoped, path-scoped */ },
    contributors() { return [TenantRoute.registration, TenantDb.registration] },
    beforeStart(ctx) { /* late DI wiring */ },
    async onHealthCheck() { return { name: 'tenant-db', status: 'up' } },
    async shutdown() { /* close what you own */ },
  }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the legacy app, that same concern was smeared across two middleware files, a&lt;br&gt;
connection registry and four request aliases. Here it's one file you can read top&lt;br&gt;
to bottom.&lt;/p&gt;
&lt;p&gt;Phases (&lt;code&gt;beforeGlobal&lt;/code&gt; / &lt;code&gt;afterGlobal&lt;/code&gt; / &lt;code&gt;beforeRoutes&lt;/code&gt; / &lt;code&gt;afterRoutes&lt;/code&gt;) and an&lt;br&gt;
optional path scope mean you rarely need to think about ordering.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;drizzle-setup-and-tenant-database-routing&quot;&gt;Drizzle setup and tenant database routing&lt;/h3&gt;
&lt;p&gt;Database-per-tenant is the part people usually ask about, so here is the whole&lt;br&gt;
mechanism. It is smaller than you'd expect, because the framework supplies the&lt;br&gt;
per-request plumbing and Drizzle supplies the rest.&lt;/p&gt;
&lt;h4 id=&quot;two-schemas-two-migration-sets&quot;&gt;Two schemas, two migration sets&lt;/h4&gt;
&lt;p&gt;The schema splits in two. A &lt;strong&gt;control&lt;/strong&gt; database holds routing and identity —&lt;br&gt;
which organisations exist, which hosts point at them, which physical database&lt;br&gt;
each one lives in. A &lt;strong&gt;tenant&lt;/strong&gt; database, one per organisation, holds everything&lt;br&gt;
else.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;src/db/schema/control/   organizations, tenant_domains, tenant_db_assignment, …
src/db/schema/tenant/    the actual domain — 240 tables
migrations/control/      0000_control-baseline.sql, …
migrations/tenant/       0000_tenant-baseline.sql, …
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two &lt;code&gt;drizzle-kit&lt;/code&gt; configs, because they migrate independently — the control&lt;br&gt;
database once, the tenant migrations across every provisioned tenant:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// drizzle.tenant.config.ts
export default defineConfig({
  schema: './src/db/schema/tenant/index.ts',
  out: './migrations/tenant',
  dialect: 'postgresql',
  casing: 'snake_case',
  dbCredentials: { url: process.env.TENANT_DATABASE_URL ?? '' },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Conventions worth locking in early, because retrofitting them across 240 tables&lt;br&gt;
is miserable: one table per file, enums in their own files, and named migrations&lt;br&gt;
only (&lt;code&gt;drizzle-kit generate --name=add-order-table&lt;/code&gt;) so git history reads as&lt;br&gt;
something other than timestamps.&lt;/p&gt;
&lt;h4 id=&quot;column-mixins&quot;&gt;Column mixins&lt;/h4&gt;
&lt;p&gt;Every table is built from the same mixins, which is how you get audit columns&lt;br&gt;
and soft delete without 240 opportunities to forget one. The audit foreign key&lt;br&gt;
is a parameter, since control-side tables point at operator users and tenant-side&lt;br&gt;
tables at tenant users:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export function withAudit(opts: AuditMixinOpts) {
  const target = resolveFk(opts)
  return {
    created_by: auditFk('created_by', target),
    created_at: timestamp('created_at', { withTimezone: true })
      .notNull().default(sql`now()`),
    updated_by: auditFk('updated_by', target),
    updated_at: timestamp('updated_at', { withTimezone: true })
      .notNull().default(sql`now()`),
  }
}

export function withAuditAndSoftDelete(opts: AuditMixinOpts) {
  return {
    ...withAudit(opts),
    deleted_at: timestamp('deleted_at', { withTimezone: true }),
    deleted_by: auditFk('deleted_by', target),
    deletion_reason: text('deletion_reason'),
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A table then reads:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const orders = pgTable('orders', {
  id: pk(),
  organization_id: fk('organization_id').notNull(),
  total_minor: bigint('total_minor', { mode: 'bigint' }).notNull(),
  currency: char('currency', { length: 3 }).notNull(),
  ...withAuditAndSoftDelete({ fkColumn: () =&amp;gt; users.id }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;fkColumn&lt;/code&gt; accepts a thunk so a self-referential table — &lt;code&gt;users.created_by&lt;/code&gt;&lt;br&gt;
pointing at &lt;code&gt;users.id&lt;/code&gt; — needs no forward declaration.&lt;/p&gt;
&lt;p&gt;For the record, the legacy schema carried audit columns in two spellings&lt;br&gt;
(&lt;code&gt;created_by&lt;/code&gt; on 175 tables, &lt;code&gt;createdBy&lt;/code&gt; on 46) and soft delete on 187 of 241.&lt;br&gt;
Normalising that is a mixin, not a migration project.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Keys are ULIDs&lt;/strong&gt;, generated client-side with a monotonic factory so ids minted&lt;br&gt;
in the same millisecond still sort in creation order:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const pk = () =&amp;gt;
  char('id', { length: 26 }).primaryKey().$defaultFn(() =&amp;gt; newId())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Postgres has no ULID type, so it's &lt;code&gt;char(26)&lt;/code&gt; — ten bytes wider than a uuid, and&lt;br&gt;
readable in a log line without decoding. Generating client-side means children&lt;br&gt;
can link to a parent before either is flushed.&lt;/p&gt;
&lt;h4 id=&quot;one-pool-per-instance-not-per-tenant&quot;&gt;One pool per instance, not per tenant&lt;/h4&gt;
&lt;p&gt;The connection layer is about forty lines. The important detail is that pools are&lt;br&gt;
cached &lt;strong&gt;by connection URL&lt;/strong&gt;, so a hundred tenants sharing one Postgres instance&lt;br&gt;
share one pool rather than opening a hundred:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const pools = new Map&amp;lt;string, postgres.Sql&amp;gt;()

function poolFor(url: string): postgres.Sql {
  let sql = pools.get(url)
  if (!sql) {
    sql = postgres(url, { max: 10, onnotice: () =&amp;gt; {} })
    pools.set(url, sql)
  }
  return sql
}

export function createTenantDb(url: string) {
  return drizzle(poolFor(url), { schema: tenantSchema })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The routing table stores an instance URL and a database name separately, so&lt;br&gt;
moving a tenant to another instance is a row update rather than a migration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export function tenantUrl(instanceUrl: string, dbName: string): string {
  const url = new URL(instanceUrl)
  url.pathname = `/${dbName}`
  return url.toString()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;routing-a-request-to-its-database&quot;&gt;Routing a request to its database&lt;/h4&gt;
&lt;p&gt;Two contributors, and this is where the framework earns its place. The first&lt;br&gt;
answers &lt;em&gt;which organisation&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const ResolveTenantRoute = defineHttpContextDecorator({
  key: 'tenantRoute',
  skipWhen: 'tenant.controlPlane',
  deps: { router: TENANT_ROUTER },
  async resolve(ctx, { router }): Promise&amp;lt;TenantRoute | null&amp;gt; {
    const headers = ctx.req.headers as Record&amp;lt;string, string | string[] | undefined&amp;gt;
    return router.resolve({
      host: pickHost(headers, { trustProxy: getEnv('TRUST_PROXY') === true }),
      tenantHeader: firstHeader(headers['x-tenant-id']),
    })
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second answers &lt;em&gt;which connection&lt;/em&gt;, and depends on the first:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const LoadTenantDb = defineHttpContextDecorator({
  key: 'tenantDb',
  skipWhen: 'tenant.controlPlane',
  dependsOn: ['tenantRoute'],
  deps: { resolver: TENANT_DB_RESOLVER },
  async resolve(ctx, { resolver }): Promise&amp;lt;TenantDb | null&amp;gt; {
    const route = ctx.get('tenantRoute')
    if (!route) return null
    return resolver.forOrg(route.orgId)
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four decisions in there are worth stealing:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Host-first, header as a fallback.&lt;/strong&gt; A host is set by the connection; a header&lt;br&gt;
is set by whoever is calling. The legacy app was header-first, which meant the&lt;br&gt;
tenant selector was fully client-controlled. Keeping &lt;code&gt;x-tenant-id&lt;/code&gt; behind a&lt;br&gt;
feature flag is what lets existing clients keep working while hosts roll out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The header is a selector, not a credential.&lt;/strong&gt; It says which tenant to route to,&lt;br&gt;
never who is calling. The actor contributor separately checks that the&lt;br&gt;
authenticated user's organisation matches the resolved one — without that, a&lt;br&gt;
header alone would be a tenant-hopping primitive.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Both resolve to &lt;code&gt;null&lt;/code&gt; rather than throwing.&lt;/strong&gt; Control-plane routes legitimately&lt;br&gt;
have no tenant, and they say so with the &lt;code&gt;tenant.controlPlane&lt;/code&gt; flag, which&lt;br&gt;
&lt;code&gt;skipWhen&lt;/code&gt; reads. A route that genuinely needs a connection is the thing that&lt;br&gt;
turns &lt;code&gt;null&lt;/code&gt; into a 400. The legacy middleware answered such requests itself with&lt;br&gt;
a 400, which is why &quot;public&quot; routes there could still be refused for tenant&lt;br&gt;
reasons nobody had asked for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;tenantDb&lt;/code&gt; depends on &lt;code&gt;tenantRoute&lt;/code&gt;, not on the actor.&lt;/strong&gt; Unauthenticated tenant&lt;br&gt;
routes — login, password reset — still need their tenant's database. Get this&lt;br&gt;
edge wrong and login is impossible on a per-tenant database.&lt;/p&gt;
&lt;p&gt;Downstream, a repository just asks for it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const db = ctx.require('tenantDb')
return db.select().from(orders).where(eq(orders.organization_id, orgId))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For comparison, the Express version registered a Sequelize connection into a&lt;br&gt;
global model registry per request, keyed by a database name formatted from the&lt;br&gt;
organisation id, in a middleware that also did authentication.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-sharp-edges-honestly&quot;&gt;The sharp edges, honestly&lt;/h3&gt;
&lt;p&gt;A post with no criticism is marketing, so:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;@Middleware()&lt;/code&gt; is not where Express middleware goes.&lt;/strong&gt; It's engine-neutral and&lt;br&gt;
calls its handler as &lt;code&gt;(ctx, next)&lt;/code&gt;. Hand it a connect-style &lt;code&gt;(req, res, next)&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;next&lt;/code&gt; binds to the response object — you get &lt;code&gt;TypeError: next is not a function&lt;/code&gt;,&lt;br&gt;
a 500 whose stack points at your middleware rather than at the mismatch. Express&lt;br&gt;
middleware belongs in an adapter's &lt;code&gt;middleware()&lt;/code&gt;. The CLI's generated template&lt;br&gt;
used to get this backwards; it now says so explicitly and points you at&lt;br&gt;
&lt;code&gt;kick g guard&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;routes()&lt;/code&gt; paths must be literals.&lt;/strong&gt; Typegen parses them statically, so hoisting&lt;br&gt;
a path into a &lt;code&gt;const&lt;/code&gt; leaves the module unbound, every handler generating with&lt;br&gt;
&lt;code&gt;contextKeys: never&lt;/code&gt; — and the type errors point at the &lt;em&gt;controller&lt;/em&gt;, not at the&lt;br&gt;
module file that caused it. Recognise the symptom:&lt;br&gt;
&lt;code&gt;Property 'x' does not exist on type 'never'&lt;/code&gt; on every handler at once.&lt;/p&gt;
&lt;p&gt;Both are the same class of problem: the framework has the right answer, but the&lt;br&gt;
shortest path from the symptom doesn't lead to it. Worth knowing up front, which&lt;br&gt;
is most of why I'm writing this.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-the-migration-itself-taught-me&quot;&gt;What the migration itself taught me&lt;/h3&gt;
&lt;p&gt;Two things I'd carry to any port, framework aside.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prove parity with a test, not with a compile.&lt;/strong&gt; Every module in the strangler&lt;br&gt;
run shipped with a test hitting the same route on both API versions and asserting&lt;br&gt;
identical status, body and content-type. It caught genuinely subtle things: a&lt;br&gt;
route registered twice, a &lt;code&gt;PUT /remove/&lt;/code&gt; where all its siblings were &lt;code&gt;DELETE&lt;/code&gt;, a&lt;br&gt;
handler returning a different response &lt;em&gt;shape&lt;/em&gt; depending on whether a query&lt;br&gt;
string was present.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Beware the heuristic you invented three modules ago.&lt;/strong&gt; I had a rule for&lt;br&gt;
predicting whether a module was portable — does the handler &lt;em&gt;return&lt;/em&gt; a value or&lt;br&gt;
&lt;em&gt;write&lt;/em&gt; to the response? It was wrong three times, always in the same direction,&lt;br&gt;
and cost two modules a second pass each. What actually predicts cost is whether&lt;br&gt;
the handler holds its own queries, which is one grep:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -c &quot;getDb(\|dbRepo\[&quot; &amp;lt;controllers&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rewriting rather than transcribing also surfaces far more. The strangler run&lt;br&gt;
accumulated about 30 recorded findings; the rewrite found &lt;strong&gt;194&lt;/strong&gt; — because&lt;br&gt;
writing behaviour fresh forces you to read every branch, where transcription&lt;br&gt;
lets you copy straight past them.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;would-i-pick-it-again&quot;&gt;Would I pick it again&lt;/h3&gt;
&lt;p&gt;For this shape of problem — a structured multi-tenant API with real per-request&lt;br&gt;
scoping — yes. The composition layer is the differentiator: contributors,&lt;br&gt;
adapters, flags and mixed mounting are what let the same framework host both an&lt;br&gt;
incremental strangler and a clean rewrite without fighting either.&lt;/p&gt;
&lt;p&gt;It is not batteries-included. You bring your own ORM and validation, the&lt;br&gt;
ecosystem is small, and some of the family packages move at different speeds. If&lt;br&gt;
you want an opinionated end-to-end stack, that's a different tool.&lt;/p&gt;
&lt;p&gt;But the primitives are the right primitives, and after 593 routes I never once&lt;br&gt;
wanted to route around them.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;links-1&quot;&gt;Links&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;KickJS&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/&quot;&gt;Homepage&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/getting-started.html&quot;&gt;Getting started&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/@forinda/kickjs&quot;&gt;&lt;code&gt;@forinda/kickjs&lt;/code&gt; on npm&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The features in this post&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/route-flags.html&quot;&gt;Route flags&lt;/a&gt; — &lt;code&gt;defineRouteFlag&lt;/code&gt;, &lt;code&gt;skipWhen&lt;/code&gt;, the policy table&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/modules.html&quot;&gt;Modules&lt;/a&gt; — &lt;code&gt;routes()&lt;/code&gt;, mounting controllers and routers&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/controllers.html&quot;&gt;Controllers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/dependency-injection.html&quot;&gt;Dependency injection&lt;/a&gt; — tokens, &lt;code&gt;deps&lt;/code&gt;, &lt;code&gt;@Autowired&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/validation.html&quot;&gt;Validation&lt;/a&gt; — zod/valibot/yup schemas on the route&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/typegen.html&quot;&gt;Typegen&lt;/a&gt; — the &lt;code&gt;KickRoutes&lt;/code&gt; namespace&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/configuration.html&quot;&gt;Configuration&lt;/a&gt; — the env schema and &lt;code&gt;@Value&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/swagger.html&quot;&gt;Swagger&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/testing.html&quot;&gt;Testing&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/generators.html&quot;&gt;Generators&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/http-runtimes.html&quot;&gt;HTTP runtimes&lt;/a&gt; — the Express / Fastify / h3 seam&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Migration&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/migration-from-express.html&quot;&gt;Migrating from Express&lt;/a&gt; — the official guide. Note it documents the &lt;em&gt;inverse&lt;/em&gt; of what I did first: KickJS as host with Express routers mounted inside.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/adapters.html&quot;&gt;Adapters&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28151/i-migrated-a-593-route-express-app-to-kickjs-twice-heres-what-held-up</guid>
<pubDate>Tue, 22 Sep 2026 11:38:03 +0000</pubDate>
</item>
<item>
<title>From a GitHub OAuth 404 to a More Reliable MyZubster Runtime</title>
<link>https://coderlegion.com/28092/from-a-github-oauth-404-to-a-more-reliable-myzubster-runtime</link>
<description>&lt;h2 id=&quot;from-a-github-oauth-404-to-a-more-reliable-myzubster-runtime&quot;&gt;From a GitHub OAuth 404 to a More Reliable MyZubster Runtime&lt;/h2&gt;
&lt;p&gt;Sometimes a production problem starts with a very small symptom.&lt;/p&gt;
&lt;p&gt;In our case, it was a GitHub OAuth &lt;code&gt;404&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;What looked like an authentication issue eventually led us through a much broader review of MyZubster’s development and runtime environment: GitHub account migration, repository ownership, systemd, PM2, MongoDB initialization, multiple Mongoose runtimes, realtime authorization, Bitcoin payment logic, and an aging Jest test suite.&lt;/p&gt;
&lt;p&gt;This is the story of how one error turned into a useful infrastructure cleanup.&lt;/p&gt;
&lt;h3 id=&quot;what-is-myzubster-&quot;&gt;What is MyZubster?&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://myzubster.com&quot;&gt;MyZubster&lt;/a&gt; is an evolving open-source ecosystem combining community participation, digital experiences, Marketplace and Seller workflows, AI assistance, experimental projects, a Metaverse interface, and open-source contribution.&lt;/p&gt;
&lt;p&gt;Useful links:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://myzubster.com&quot;&gt;https://myzubster.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/danieldirimini-myzubster/myzubster&quot;&gt;https://github.com/danieldirimini-myzubster/myzubster&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Marketplace: &lt;a rel=&quot;nofollow&quot; href=&quot;https://myzubster.com/marketplace&quot;&gt;https://myzubster.com/marketplace&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Metaverse: &lt;a rel=&quot;nofollow&quot; href=&quot;https://myzubster.com/metaverse&quot;&gt;https://myzubster.com/metaverse&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;LIFE Pilot: &lt;a rel=&quot;nofollow&quot; href=&quot;https://myzubster.com/life-pilot&quot;&gt;https://myzubster.com/life-pilot&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The project is actively evolving, and part of the current work is making sure the codebase, infrastructure, runtime behavior, and tests all describe the same system.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-original-problem-github-oauth&quot;&gt;The original problem: GitHub OAuth&lt;/h3&gt;
&lt;p&gt;The first visible issue was an unreliable GitHub authentication flow that could end in a &lt;code&gt;404&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;At the same time, the previous development account had accumulated other operational limitations affecting API access, OAuth integrations, Actions, and repository workflows.&lt;/p&gt;
&lt;p&gt;Instead of trying to keep building on top of an unstable setup, we separated two concerns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the historical/public project identity;&lt;/li&gt;
&lt;li&gt;the operational GitHub identity used for development.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A new GitHub account became the operational development account, while the existing MyZubster identity inside the product remained untouched.&lt;/p&gt;
&lt;p&gt;This was important.&lt;/p&gt;
&lt;p&gt;We were changing the development control plane, not rewriting the identity or history of the project.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;migrating-git-without-destroying-local-state&quot;&gt;Migrating Git without destroying local state&lt;/h3&gt;
&lt;p&gt;The active VPS repository is located at:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;/root/myzubster
The server also contains worktrees, experimental directories, verifier code, and local files that should not be committed.
So we deliberately avoided shortcuts like:
git reset --hard
and:
git add .
Instead, we moved the canonical remote to the new GitHub repository while keeping older remotes available for reference.
The current development repository is:
https://github.com/danieldirimini-myzubster/myzubster
The active feature branch was then synchronized with the latest main without rewriting history.
That preserved the work already done while bringing in an OAuth-related fix from the main branch.
Then we found a runtime ownership problem
Once Git was stable, we inspected how MyZubster was actually running on the VPS.
We discovered that the environment used both:
systemd
PM2
That is not necessarily a problem by itself.
The real issue was that both systems appeared to have responsibility for parts of the same application.
There was a PM2 process named myzubster, while the actual HTTP gateway was being served through systemd.
Even worse, the startup paths were different.
The root server.js exported the Express application, while another process was responsible for calling .listen().
That meant we had multiple process managers around an application that did not have a single, obvious runtime owner.
The solution was to make systemd the canonical supervisor for the main gateway and remove the duplicate PM2 ownership.
PM2 remains useful for other independent services.
Production was running from an older project directory
Another subtle problem appeared in the systemd configuration.
The active repository was:
/root/myzubster
but the service still referenced an older copy:
/root/myzubster-ahp
This is a dangerous situation because you can successfully edit, test, commit, and push one repository while production continues to execute another.
We updated the systemd working directory so the code being edited and the code being executed are now aligned.
This is one of the most important lessons from this debugging session:
Never assume the repository you are editing is the repository your production process is actually running.

Check WorkingDirectory.
Check ExecStart.
Check the real process.
The homepage worked, but the Metaverse was degraded
After fixing the runtime path, the homepage returned:
HTTP 200
But the Metaverse health endpoint reported:
{
  &quot;success&quot;: false,
  &quot;status&quot;: &quot;degraded&quot;,
  &quot;transport&quot;: &quot;unavailable&quot;,
  &quot;mongodb&quot;: &quot;disconnected&quot;
}
At first this looked like a MongoDB problem.
It was not.
The credentials were correct and the root application could connect to the database.
The real problem was much more interesting.
Two Mongoose installations meant two database states
The project contains separate Node.js dependency contexts.
Conceptually:
root application
  -&amp;gt; Mongoose 7

backend
  -&amp;gt; Mongoose 8
Those are two separate JavaScript instances.
Connecting one does not connect the other.
So this:
await rootMongoose.connect(...)
does not make this true:
backendMongoose.connection.readyState === 1
The backend Metaverse code was checking its own Mongoose connection.
That connection had never been initialized by the systemd startup path.
The database itself was fine.
The application lifecycle was wrong.
Fixing the bootstrap sequence
The backend already had proper database initialization logic.
The missing piece was making sure production actually called it.
We created a dedicated systemd entrypoint that does this in the correct order:
load application
      ↓
connect backend MongoDB
      ↓
start HTTP server
In simplified Node.js:
const app = require('../server');
const backend = require('../backend/src');

async function main() {
  await backend.connectDatabase();

  const server = app.listen(5003, '127.0.0.1', () =&amp;gt; {
    console.log('MyZubster Gateway ready');
  });

  process.on('SIGTERM', async () =&amp;gt; {
    server.close(async () =&amp;gt; {
      await backend.disconnectDatabase();
      process.exit(0);
    });
  });
}

main();
After restarting systemd, the Metaverse health endpoint changed to:
{
  &quot;success&quot;: true,
  &quot;status&quot;: &quot;healthy&quot;,
  &quot;transport&quot;: &quot;shared-polling&quot;,
  &quot;mongodb&quot;: &quot;connected&quot;
}
That was a major milestone.
We did not “solve” Mongoose by deleting dependencies
A tempting response would have been to force both parts of the project to use one Mongoose installation immediately.
We deliberately did not do that.
The immediate problem was not the existence of two Mongoose versions.
The problem was that only one connection lifecycle was being initialized.
This distinction matters.
A structural refactor may still make sense in the future, but production incidents are not the right time to perform unnecessary dependency surgery.
Fix the real failure first.
Then the tests started teaching us about the architecture
The feature-specific tests were healthy.
For the MYZ-188 development work, we had:
7 test suites passed
37 tests passed
But the complete repository test suite still contained failures.
Instead of assuming the new branch had broken everything, we checked whether several failures also existed on main.
Many did.
That gave us a useful distinction:
new regression
versus:
existing technical debt
This saved us from “fixing” unrelated code based only on the fact that Jest was red.
A red test does not always mean production is wrong
One example involved the monthly AI budget.
A test expected a budget key like:
openai:gpt-5.6-sol:2026-09
while production used:
openai:all:2026-09
At first glance, changing the production key would make the test pass.
But when we reviewed the architecture, the global key was clearly intentional.
The monthly budget covers the whole OpenAI/Astra usage, and the system may fall back between different OpenAI models.
Using model-specific budget keys could allow separate models to consume separate caps.
So the production behavior was correct.
The test was stale.
We updated the test.
That is an important rule:
Tests are executable documentation, but executable documentation can become outdated too.

Realtime authorization: fail closed
Another test was hanging for five seconds.
The relevant code queried community membership through MongoDB.
When the database authority was unavailable, Mongoose buffered the query until Jest timed out.
The correct behavior for authorization was not to wait.
It was to deny.
We changed the flow to check database availability first:
if (mongoose.connection.readyState !== 1) {
  return {
    allowed: false,
    reason: 'community_membership_authority_unavailable'
  };
}
The behavior changed from:
database unavailable
    ↓
wait
    ↓
timeout
to:
database unavailable
    ↓
deny immediately
That is both faster and safer.
Then we found a real Bitcoin bug
The full suite exposed another issue:
TypeError:
Cannot read properties of undefined (reading 'includes')
The failing code was checking:
SUPPORTED_ASSETS.includes(asset)
But SUPPORTED_ASSETS itself was undefined.
The reason was a CommonJS circular dependency.
The dependency graph looked approximately like this:
Legacy Monetization Service
          ↓
Unified Checkout Service
          ↓
Quote / Chain Verifier
          ↓
Legacy Monetization Service
During module initialization, Node.js returned an incomplete export.
The result:
SUPPORTED_ASSETS === undefined
Breaking the circular dependency
We did not add a defensive fallback like:
SUPPORTED_ASSETS || []
That would hide the design problem.
Instead, we extracted the constant into a leaf module:
'use strict';

const SUPPORTED_ASSETS = Object.freeze(['BTC']);

module.exports = {
  SUPPORTED_ASSETS
};
Now the payment services depend on a small module that depends on nothing else.
The Bitcoin production tests moved from:
3 failed
2 passed
to:
5 passed
The tests cover:
- BTC availability;
- BTC/EUR quoting;
- satoshi conversion;
- Esplora verification;
- unconfirmed-payment fail-closed behavior.
Legacy tests were still mocking an older architecture
The next group of failures was especially interesting.
Several tests still mocked:
ZorgaxPaymentIntent
ZorgaxSubscription
But the current production architecture uses:
PaymentIntent
ZorgaxPurchase
Entitlement
That means the Jest mocks looked correct, but they were intercepting classes the application no longer used.
The real Mongoose models were executing instead.
Then Jest waited for the real database.
Five seconds later:
Exceeded timeout of 5000 ms
The problem was not a slow application.
It was a stale test boundary.
Test runtime: 11 seconds to less than one
One replay-protection test originally took around:
11 seconds
and failed with two timeouts.
After updating the test to mock the real architecture:
2 tests passed
in roughly:
0.7 seconds
That is a strong indicator that the previous problem was accidental external I/O.
The new replay protection verifies something closer to the real current contract:
same payment reference
        ↓
existing purchase?
        ↓
same owner?
   ┌────┴────┐
  yes        no
   ↓          ↓
 reuse      reject
Only after ownership is validated does the system grant the entitlement.
What we are doing now
The main runtime is now much healthier.
We have already completed:
- migration to the new operational GitHub account;
- repository remote cleanup;
- branch synchronization with main;
- OAuth regression verification;
- systemd runtime cleanup;
- duplicate PM2 gateway removal;
- backend MongoDB bootstrap;
- Metaverse health recovery;
- graceful backend database shutdown;
- Zorgax access compatibility cleanup;
- AI budget test alignment;
- realtime fail-closed authorization;
- Bitcoin circular-dependency removal;
- modernization of several outdated payment tests.
The current focus is the remaining legacy test suite.
Some tests still represent old implementation details instead of the current architecture.
So we are going through them carefully and asking:
Is this a production bug, or is the test describing a system that no longer exists?

That is the work happening right now.
The bigger architectural goal
The cleanup is gradually making the boundaries clearer:
systemd
  -&amp;gt; explicit gateway bootstrap

gateway bootstrap
  -&amp;gt; explicit database lifecycle

realtime authorization
  -&amp;gt; explicit availability checks

payments
  -&amp;gt; unified checkout boundary

payment constants
  -&amp;gt; dependency-free leaf module

access
  -&amp;gt; entitlement authority

tests
  -&amp;gt; mock current production boundaries
The goal is not merely to get a green test suite.
The goal is to have a green test suite that means something.
Small commits are helping a lot
We are keeping fixes isolated.
Examples include:
fix(runtime): initialize backend Mongo before gateway listen

fix(zorgax): include optional legacy subscription access source

test(zorgax): align AI budget expectation with global monthly cap

fix(realtime): fail closed when community membership authority is unavailable

fix(zorgax): break payment asset circular dependency
Small commits give us three advantages:
- easier review;
- easier rollback;
- easier reasoning.
Git becomes part of the debugging process, not just version storage.
What we deliberately did not do
Some shortcuts would have made the situation worse.
We did not:
reset the repository destructively
delete worktrees blindly
force-de-duplicate Mongoose during an incident
increase Jest timeouts to hide MongoDB access
hide circular dependencies with fallback values
commit credentials or environment secrets
A large part of production engineering is not just knowing what command to run.
It is knowing which commands not to run.
Current simplified architecture
The current direction looks like this:
GitHub
   ↓
danieldirimini-myzubster/myzubster
   ↓
VPS /root/myzubster
   ↓
systemd
   ↓
MyZubster Gateway
   ├── Root application
   ├── Backend Mongo context
   ├── Metaverse
   ├── Realtime
   ├── Zorgax
   └── Payment services
Other independent services can still use PM2 where appropriate.
The important improvement is that there is now a much clearer owner for each runtime responsibility.
What comes next
The immediate plan is:
1. finish updating the remaining legacy payment tests;
2. review source-inspection tests that still point to compatibility wrappers;
3. run the complete Jest suite again;
4. separate real regressions from intentionally changed contracts;
5. verify production health;
6. prepare the branch for review and merge into main.
The goal is simple:
align the repository, runtime, architecture, and tests before merging.

Final lesson
This entire debugging session started with:
GitHub OAuth 404
and evolved into:
GitHub migration
      ↓
runtime cleanup
      ↓
systemd stabilization
      ↓
MongoDB lifecycle fix
      ↓
Metaverse recovery
      ↓
authorization hardening
      ↓
Bitcoin dependency cleanup
      ↓
legacy test modernization
That is often how real engineering works.
The first visible error is not always the real problem.
Sometimes it is simply the first place where the system tells you to look deeper.
We are continuing that work now on MyZubster.
Project:
https://myzubster.com
GitHub:
https://github.com/danieldirimini-myzubster/myzubster
Marketplace:
https://myzubster.com/marketplace
Metaverse:
https://myzubster.com/metaverse
LIFE Pilot:
https://myzubster.com/life-pilot
The next milestone is to finish the legacy test alignment, validate the entire branch, and prepare it for merge into main.
One boundary at a time.

&lt;/code&gt;&lt;/pre&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28092/from-a-github-oauth-404-to-a-more-reliable-myzubster-runtime</guid>
<pubDate>Tue, 22 Sep 2026 03:59:35 +0000</pubDate>
</item>
<item>
<title>The Silent Performance Killer: Demystifying the N+1 Query Problem in Node.js</title>
<link>https://coderlegion.com/27905/the-silent-performance-killer-demystifying-the-n-1-query-problem-in-node-js</link>
<description>&lt;p&gt;Have you ever noticed a feature that works beautifully on your local computer, only to crawl at a snail's pace once it gets deployed to production? One of the most common, yet invisible, reasons for this sudden slowdown is a database fetching behavior known as the N+1 query problem.&lt;/p&gt;
&lt;p&gt;In short, the N+1 query problem occurs when an application makes one initial database query to fetch a list of records (the &quot;1&quot;), and then makes a separate database query for every single record returned in that list (the &quot;N&quot;) to fetch related data. Instead of fetching all the required information in a single, efficient request, the application ends up chatting back and forth with the database dozens, hundreds, or thousands of times.&lt;/p&gt;
&lt;h4 id=&quot;the-restaurant-server-analogy&quot;&gt;The Restaurant Server Analogy&lt;/h4&gt;
&lt;p&gt;To understand why this is so inefficient, imagine sitting down at a busy restaurant with a table of ten friends. It is time to order drinks.&lt;/p&gt;
&lt;p&gt;An efficient server (who behaves like an optimized database query) would take everyone's drink orders on a notepad, walk to the kitchen once, load all ten glasses onto a single tray, and bring them back to the table in one trip. This requires just one round-trip to the kitchen.&lt;/p&gt;
&lt;p&gt;Now imagine an N+1 server. This server walks to the table, asks the first person what they want, and walks all the way back to the kitchen to fetch that single drink. After serving it, they walk back to the table, ask the second person for their order, and make another complete round-trip to the kitchen. They repeat this individual process for all ten people. By the time everyone has a drink, the server has made eleven total trips (1 trip to inspect the table, plus N trips for the N guests). Your dining experience would be incredibly slow, and the poor server would be completely exhausted.&lt;/p&gt;
&lt;h4 id=&quot;why-this-matters-in-daily-engineering&quot;&gt;Why This Matters in Daily Engineering&lt;/h4&gt;
&lt;p&gt;In real-world software engineering, database round-trips are remarkably expensive. Every single query you run introduces network latency, database parsing overhead, and thread consumption in your database's connection pool.&lt;/p&gt;
&lt;p&gt;If you are building a social media dashboard that displays 50 posts, and you load the comments for each post individually, you are executing 51 database queries. If 1,000 users visit that page simultaneously, your application will suddenly bombard your database with 51,000 queries. This easily exhausts your database connection pool, drives database CPU usage to 100%, and results in catastrophic timeouts for your users. Engineers must actively prevent this to keep application response times low and infrastructure costs manageable.&lt;/p&gt;
&lt;h4 id=&quot;seeing-it-in-action-node-js-and-mysql&quot;&gt;Seeing It in Action: Node.js and MySQL&lt;/h4&gt;
&lt;p&gt;Let's look at a typical Express.js endpoint where this mistake happens, followed by how we fix it.&lt;/p&gt;
&lt;p&gt;Here is the problematic, naive approach:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// BAD: This triggers N+1 queries
app.get('/api/posts', async (req, res) =&amp;gt; {
  try {
    // 1. Fetch all posts (The &quot;1&quot; query)
    const [posts] = await db.query('SELECT id, title, content FROM posts LIMIT 50');
    
    // 2. Loop through each post to fetch its comments (The &quot;N&quot; queries)
    for (let post of posts) {
      const [comments] = await db.query(
        'SELECT id, text FROM comments WHERE post_id = ?',
        [post.id]
      );
      post.comments = comments;
    }
    
    res.json(posts);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is how we resolve this using a SQL JOIN to pull all the information in a single, efficient query:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// GOOD: Resolved using a single JOIN query
app.get('/api/posts', async (req, res) =&amp;gt; {
  try {
    const query = `
      SELECT 
        p.id AS post_id, 
        p.title, 
        p.content, 
        c.id AS comment_id, 
        c.text AS comment_text
      FROM posts p
      LEFT JOIN comments c ON p.id = c.post_id
      LIMIT 50
    `;
    
    const [rows] = await db.query(query);
    
    // Group the flat SQL results into structured JSON
    const postsMap = {};
    for (const row of rows) {
      if (!postsMap[row.post_id]) {
        postsMap[row.post_id] = {
          id: row.post_id,
          title: row.title,
          content: row.content,
          comments: []
        };
      }
      if (row.comment_id) {
        postsMap[row.post_id].comments.push({
          id: row.comment_id,
          text: row.comment_text
        });
      }
    }
    
    res.json(Object.values(postsMap));
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-takeaway-1&quot;&gt;The Takeaway&lt;/h4&gt;
&lt;p&gt;The N+1 query problem highlights that how you fetch your data is just as important as the data itself. By switching from sequential, iterative loops to combined database operations like SQL JOINs, you minimize the highly expensive overhead of network round-trips. Designing your data fetching strategies proactively keeps your APIs incredibly fast and ensures your databases stay healthy, stable, and cost-efficient under high production loads.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/09/how-to-save-your-database-understanding.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27905/the-silent-performance-killer-demystifying-the-n-1-query-problem-in-node-js</guid>
<pubDate>Mon, 21 Sep 2026 09:18:48 +0000</pubDate>
</item>
<item>
<title>We Rebuilt MYZ Payments Around a Canonical Ledger — Here’s What We Learned</title>
<link>https://coderlegion.com/27661/we-rebuilt-myz-payments-around-a-canonical-ledger-heres-what-we-learned</link>
<description>&lt;p&gt;We Rebuilt MYZ Payments Around a Canonical Ledger — Here’s What We Learned&lt;/p&gt;
&lt;p&gt;When we started adding internal MYZ payments to MyZubster Marketplace, the obvious task looked simple:&lt;/p&gt;
&lt;p&gt;buyer balance -= amount&lt;br&gt;
seller balance += amount&lt;/p&gt;
&lt;p&gt;That turned out to be the wrong level of abstraction.&lt;/p&gt;
&lt;p&gt;The real challenge was not adding a payment button. It was making sure that every part of the application agreed on what a MYZ balance actually is.&lt;/p&gt;
&lt;p&gt;During the implementation, we found multiple historical paths capable of representing or modifying MYZ independently.&lt;/p&gt;
&lt;p&gt;That is exactly the kind of situation that eventually produces accounting bugs.&lt;/p&gt;
&lt;p&gt;So instead of adding another payment path, we refactored the system around one rule:&lt;/p&gt;
&lt;p&gt;There must be one canonical source of truth for spendable MYZ.&lt;/p&gt;
&lt;p&gt;This post covers the architecture we implemented, the problems we found, and the trade-offs that are still open.&lt;/p&gt;
&lt;p&gt;The original problem: balances were too easy to create&lt;/p&gt;
&lt;p&gt;A field like this looks harmless:&lt;/p&gt;
&lt;p&gt;{&lt;br&gt;
  userId,&lt;br&gt;
  balanceMYZ: 500&lt;br&gt;
}&lt;/p&gt;
&lt;p&gt;Until another subsystem has:&lt;/p&gt;
&lt;p&gt;{&lt;br&gt;
  accountId,&lt;br&gt;
  myzCredit: 200&lt;br&gt;
}&lt;/p&gt;
&lt;p&gt;And another computes:&lt;/p&gt;
&lt;p&gt;displayedBalance =&lt;br&gt;
  ledgerBalance +&lt;br&gt;
  mongoCredits -&lt;br&gt;
  utilityRedemptions;&lt;/p&gt;
&lt;p&gt;Now the question becomes:&lt;/p&gt;
&lt;p&gt;What is the user's real balance?&lt;/p&gt;
&lt;p&gt;If multiple components can answer that question differently, the system no longer has a reliable accounting model.&lt;/p&gt;
&lt;p&gt;That was the architectural problem we wanted to eliminate.&lt;/p&gt;
&lt;p&gt;First principle: MYZ is an internal accounting unit&lt;/p&gt;
&lt;p&gt;Before touching the code, we established the semantics.&lt;/p&gt;
&lt;p&gt;MYZ is an internal MyZubster utility/accounting credit.&lt;/p&gt;
&lt;p&gt;It is not automatically convertible into:&lt;/p&gt;
&lt;p&gt;EUR&lt;br&gt;
BTC&lt;br&gt;
XMR&lt;br&gt;
cash&lt;/p&gt;
&lt;p&gt;and it is not represented by this implementation as an on-chain token.&lt;/p&gt;
&lt;p&gt;This matters technically because it means we do not need an exchange-rate engine in order to spend MYZ.&lt;/p&gt;
&lt;p&gt;If a Marketplace listing costs:&lt;/p&gt;
&lt;p&gt;250 MYZ&lt;/p&gt;
&lt;p&gt;the payment is simply:&lt;/p&gt;
&lt;p&gt;250 MYZ&lt;/p&gt;
&lt;p&gt;There is no intermediate:&lt;/p&gt;
&lt;p&gt;250 MYZ → €X → seller&lt;/p&gt;
&lt;p&gt;Likewise, a Zorgax plan can have an explicit MYZ price without deriving it from its EUR price.&lt;/p&gt;
&lt;p&gt;The canonical ledger&lt;/p&gt;
&lt;p&gt;The core service is:&lt;/p&gt;
&lt;p&gt;src/services/myzLedgerApiService.js&lt;/p&gt;
&lt;p&gt;Instead of mutating a user balance directly, transfers are recorded as ledger entries.&lt;/p&gt;
&lt;p&gt;A payment creates two legs:&lt;/p&gt;
&lt;p&gt;buyer    -250 MYZ&lt;br&gt;
seller   +250 MYZ&lt;/p&gt;
&lt;p&gt;Both are linked by the same:&lt;/p&gt;
&lt;p&gt;transfer_id&lt;/p&gt;
&lt;p&gt;Conceptually:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;              transfer_id: T-123
             ┌─────────────────┐
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;buyer account    │ -250 MYZ        │&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;             │                 │
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;seller account   │ +250 MYZ        │&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;             └─────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives us something a plain balance field does not provide naturally:&lt;/p&gt;
&lt;p&gt;auditability.&lt;/p&gt;
&lt;p&gt;We can answer:&lt;/p&gt;
&lt;p&gt;Why is this balance 750 MYZ?&lt;/p&gt;
&lt;p&gt;by looking at the entries that produced it.&lt;/p&gt;
&lt;p&gt;Transfers are atomic inside the ledger&lt;/p&gt;
&lt;p&gt;The transfer operation performs both accounting legs under the same write operation.&lt;/p&gt;
&lt;p&gt;The important invariant is:&lt;/p&gt;
&lt;p&gt;debit exists  ⇔  credit exists&lt;/p&gt;
&lt;p&gt;We do not want:&lt;/p&gt;
&lt;p&gt;buyer -250&lt;/p&gt;
&lt;p&gt;without:&lt;/p&gt;
&lt;p&gt;seller +250&lt;/p&gt;
&lt;p&gt;because the process crashed halfway through.&lt;/p&gt;
&lt;p&gt;The ledger therefore validates the transfer first, then persists both entries together.&lt;/p&gt;
&lt;p&gt;The relevant checks include:&lt;/p&gt;
&lt;p&gt;valid payer&lt;br&gt;
valid payee&lt;br&gt;
payer != payee&lt;br&gt;
valid amount&lt;br&gt;
sufficient balance&lt;br&gt;
valid idempotency key&lt;br&gt;
no transfer ID collision&lt;/p&gt;
&lt;p&gt;Only after those checks do the entries become recorded.&lt;/p&gt;
&lt;p&gt;Never trust the UI to prevent overspending&lt;/p&gt;
&lt;p&gt;One of the tests we cared about most was not:&lt;/p&gt;
&lt;p&gt;Can Alice buy an item?&lt;/p&gt;
&lt;p&gt;It was:&lt;/p&gt;
&lt;p&gt;Can Alice buy two items at the same time&lt;br&gt;
when she can only afford one?&lt;/p&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;p&gt;Alice balance: 300 MYZ&lt;/p&gt;
&lt;p&gt;Purchase A: 250 MYZ&lt;br&gt;
Purchase B: 250 MYZ&lt;/p&gt;
&lt;p&gt;Both requests may arrive concurrently.&lt;/p&gt;
&lt;p&gt;A naïve implementation can do this:&lt;/p&gt;
&lt;p&gt;if (balance &amp;gt;= 250) {&lt;br&gt;
  // request A sees 300&lt;br&gt;
}&lt;/p&gt;
&lt;p&gt;if (balance &amp;gt;= 250) {&lt;br&gt;
  // request B also sees 300&lt;br&gt;
}&lt;/p&gt;
&lt;p&gt;Then both succeed.&lt;/p&gt;
&lt;p&gt;Final logical balance:&lt;/p&gt;
&lt;p&gt;-200 MYZ&lt;/p&gt;
&lt;p&gt;That is unacceptable for an accounting system.&lt;/p&gt;
&lt;p&gt;Our expected result is:&lt;/p&gt;
&lt;p&gt;one purchase succeeds&lt;br&gt;
one purchase fails&lt;br&gt;
final balance = 50 MYZ&lt;/p&gt;
&lt;p&gt;The protection belongs in the ledger write path, not in React.&lt;/p&gt;
&lt;p&gt;Idempotency became a first-class concept&lt;/p&gt;
&lt;p&gt;Payment APIs are retried.&lt;/p&gt;
&lt;p&gt;Browsers retry.&lt;/p&gt;
&lt;p&gt;Networks fail.&lt;/p&gt;
&lt;p&gt;Users double-click.&lt;/p&gt;
&lt;p&gt;Servers can complete the financial operation and crash before returning 200 OK.&lt;/p&gt;
&lt;p&gt;So this API:&lt;/p&gt;
&lt;p&gt;POST /api/marketplace/orders/:id/payment/myz&lt;/p&gt;
&lt;p&gt;requires an idempotency key.&lt;/p&gt;
&lt;p&gt;A simplified request looks like:&lt;/p&gt;
&lt;p&gt;Idempotency-Key: marketplace-order-123&lt;/p&gt;
&lt;p&gt;If the exact operation is retried, we return the original transfer instead of charging again.&lt;/p&gt;
&lt;p&gt;But there is another subtle problem.&lt;/p&gt;
&lt;p&gt;What happens if somebody reuses the same idempotency key with a different payload?&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;p&gt;key = abc&lt;br&gt;
plan = Zorgax Pro&lt;br&gt;
amount = 40 MYZ&lt;/p&gt;
&lt;p&gt;followed by:&lt;/p&gt;
&lt;p&gt;key = abc&lt;br&gt;
plan = Zorgax Developer&lt;br&gt;
amount = 40 MYZ&lt;/p&gt;
&lt;p&gt;The amount is identical, but the operation is not.&lt;/p&gt;
&lt;p&gt;So idempotency is bound not only to the numeric amount, but also to the semantic reference of the operation.&lt;/p&gt;
&lt;p&gt;Same key + different meaning = conflict.&lt;/p&gt;
&lt;p&gt;That protects against a class of bugs that simple &quot;key already exists&quot; implementations often miss.&lt;/p&gt;
&lt;p&gt;Marketplace became a true buyer → seller transfer&lt;/p&gt;
&lt;p&gt;For MYZ-priced Marketplace orders, the flow is now:&lt;/p&gt;
&lt;p&gt;Buyer&lt;br&gt;
  ↓&lt;br&gt;
canonical MYZ transfer&lt;br&gt;
  ↓&lt;br&gt;
Seller&lt;br&gt;
  ↓&lt;br&gt;
order marked PAID&lt;/p&gt;
&lt;p&gt;The order stores evidence of the accounting operation:&lt;/p&gt;
&lt;p&gt;payment: {&lt;br&gt;
  asset: 'MYZ',&lt;br&gt;
  network: 'internal-ledger',&lt;br&gt;
  status: 'PAID',&lt;br&gt;
  transferId,&lt;br&gt;
  debitEntryId,&lt;br&gt;
  creditEntryId&lt;br&gt;
}&lt;/p&gt;
&lt;p&gt;We also enforce several business invariants.&lt;/p&gt;
&lt;p&gt;The buyer must be authenticated.&lt;/p&gt;
&lt;p&gt;The buyer must actually own the order.&lt;/p&gt;
&lt;p&gt;Buyer and seller cannot be the same account.&lt;/p&gt;
&lt;p&gt;The order must be accepted before payment.&lt;/p&gt;
&lt;p&gt;And an unpaid MYZ order cannot transition to completed.&lt;/p&gt;
&lt;p&gt;The important point is that these rules are checked server-side.&lt;/p&gt;
&lt;p&gt;The React button is just UX.&lt;/p&gt;
&lt;p&gt;Application state and ledger state are not the same transaction&lt;/p&gt;
&lt;p&gt;There is an interesting systems problem here.&lt;/p&gt;
&lt;p&gt;The MYZ ledger and the Marketplace order are not stored in the same transactional database.&lt;/p&gt;
&lt;p&gt;So this can happen:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;MYZ transfer succeeds&lt;/li&gt;
&lt;li&gt;application crashes&lt;/li&gt;
&lt;li&gt;order has not yet been marked PAID&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You cannot solve that with a normal database transaction across unrelated stores.&lt;/p&gt;
&lt;p&gt;Instead, we use an idempotent recovery model.&lt;/p&gt;
&lt;p&gt;On retry:&lt;/p&gt;
&lt;p&gt;ledger sees existing transfer&lt;br&gt;
→ does not charge again&lt;/p&gt;
&lt;p&gt;application receives original receipt&lt;br&gt;
→ retries order persistence&lt;/p&gt;
&lt;p&gt;This is closer to a small saga than a traditional ACID transaction.&lt;/p&gt;
&lt;p&gt;The important requirement is recoverability.&lt;/p&gt;
&lt;p&gt;Zorgax uses the same accounting primitive&lt;/p&gt;
&lt;p&gt;We reused the same ledger for Zorgax plans.&lt;/p&gt;
&lt;p&gt;The flow is:&lt;/p&gt;
&lt;p&gt;user MYZ account&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  ↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;canonical transfer&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  ↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;zorgax:system:treasury&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  ↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;grant entitlement&lt;/p&gt;
&lt;p&gt;Prices are configuration values:&lt;/p&gt;
&lt;p&gt;ZORGAX_PRO_PRICE_MYZ&lt;br&gt;
ZORGAX_DEVELOPER_PRICE_MYZ&lt;/p&gt;
&lt;p&gt;They are deliberately not calculated from:&lt;/p&gt;
&lt;p&gt;priceEur&lt;/p&gt;
&lt;p&gt;If the MYZ price is missing, the checkout fails closed.&lt;/p&gt;
&lt;p&gt;Production currently reports the MYZ plan as unavailable instead of guessing a price.&lt;/p&gt;
&lt;p&gt;That is a feature, not a failure.&lt;/p&gt;
&lt;p&gt;The most important refactor was deleting shadow accounting&lt;/p&gt;
&lt;p&gt;While reviewing the system, we found an older Payment Dashboard flow that effectively calculated MYZ like this:&lt;/p&gt;
&lt;p&gt;canonical ledger&lt;br&gt;
+&lt;/p&gt;
&lt;h3 id=&quot;mongo-credits&quot;&gt;Mongo credits&lt;/h3&gt;
&lt;p&gt;Mongo redemptions&lt;/p&gt;
&lt;p&gt;It also contained an automatic EUR → MYZ rule.&lt;/p&gt;
&lt;p&gt;That meant the canonical ledger was not actually canonical.&lt;/p&gt;
&lt;p&gt;So we changed the model.&lt;/p&gt;
&lt;p&gt;New Stripe payments do not automatically generate MYZ.&lt;/p&gt;
&lt;p&gt;Historical Mongo conversion records remain available as legacy audit data, but they no longer contribute to spendable MYZ.&lt;/p&gt;
&lt;p&gt;Utility spending now hits the canonical ledger.&lt;/p&gt;
&lt;p&gt;MongoDB redemption records are retained for things like:&lt;/p&gt;
&lt;p&gt;fulfillment status&lt;br&gt;
receipt metadata&lt;br&gt;
application workflow&lt;/p&gt;
&lt;p&gt;but they are no longer a second balance system.&lt;/p&gt;
&lt;p&gt;That distinction is extremely useful:&lt;/p&gt;
&lt;p&gt;ledger = accounting truth&lt;/p&gt;
&lt;p&gt;application database = business state&lt;br&gt;
Then we found another hard-coded system: Marketplace categories&lt;/p&gt;
&lt;p&gt;The same development cycle uncovered a less dangerous but very visible problem.&lt;/p&gt;
&lt;p&gt;The React Marketplace category list was mostly hard-coded.&lt;/p&gt;
&lt;p&gt;That means adding a category required changing application code.&lt;/p&gt;
&lt;p&gt;We replaced that with a dynamic catalog:&lt;/p&gt;
&lt;p&gt;GET /api/listings/categories&lt;/p&gt;
&lt;p&gt;Users can now propose new categories from the Marketplace itself.&lt;/p&gt;
&lt;p&gt;The interesting UX decision is that the creator can use their pending category immediately for their own listing.&lt;/p&gt;
&lt;p&gt;Global visibility still requires moderation.&lt;/p&gt;
&lt;p&gt;So the flow becomes:&lt;/p&gt;
&lt;p&gt;create category&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  ↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;pending&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  ↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;creator may use it&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  ↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;admin review&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  ↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;global category&lt;/p&gt;
&lt;p&gt;This is much more flexible than making category taxonomy a deploy-time decision.&lt;/p&gt;
&lt;p&gt;Knowledge became a Marketplace object too&lt;/p&gt;
&lt;p&gt;We also introduced a first-class:&lt;/p&gt;
&lt;p&gt;knowledge&lt;/p&gt;
&lt;p&gt;category.&lt;/p&gt;
&lt;p&gt;Users can submit things like:&lt;/p&gt;
&lt;p&gt;guides&lt;br&gt;
methods&lt;br&gt;
know-how&lt;br&gt;
tutorials&lt;br&gt;
manuals&lt;br&gt;
practical skills&lt;/p&gt;
&lt;p&gt;through a separate knowledge submission flow.&lt;/p&gt;
&lt;p&gt;But we explicitly did not connect submission directly to rewards.&lt;/p&gt;
&lt;p&gt;A new knowledge item starts as:&lt;/p&gt;
&lt;p&gt;PENDING_REVIEW&lt;/p&gt;
&lt;p&gt;The API response explicitly indicates that creation does not produce:&lt;/p&gt;
&lt;p&gt;reward&lt;br&gt;
ledger write&lt;br&gt;
MYZ transfer&lt;/p&gt;
&lt;p&gt;This separation matters.&lt;/p&gt;
&lt;p&gt;A useful architecture is:&lt;/p&gt;
&lt;p&gt;submission&lt;br&gt;
   ↓&lt;br&gt;
review&lt;br&gt;
   ↓&lt;br&gt;
verification&lt;br&gt;
   ↓&lt;br&gt;
optional reward&lt;/p&gt;
&lt;p&gt;not:&lt;/p&gt;
&lt;p&gt;user submits text&lt;br&gt;
   ↓&lt;br&gt;
money appears&lt;br&gt;
Deployment uncovered another important distinction&lt;/p&gt;
&lt;p&gt;The code was merged through PR #1286.&lt;/p&gt;
&lt;p&gt;Production deployment succeeded on Vercel.&lt;/p&gt;
&lt;p&gt;Marketplace category endpoints respond correctly.&lt;/p&gt;
&lt;p&gt;knowledge is available in production.&lt;/p&gt;
&lt;p&gt;No fatal/runtime errors appeared during our verification window.&lt;/p&gt;
&lt;p&gt;But we are intentionally not saying the MYZ accounting system is fully production-ready yet.&lt;/p&gt;
&lt;p&gt;Why?&lt;/p&gt;
&lt;p&gt;Because the current ledger implementation is file-based.&lt;/p&gt;
&lt;p&gt;And there is a big difference between:&lt;/p&gt;
&lt;p&gt;the application can write a file&lt;/p&gt;
&lt;p&gt;and:&lt;/p&gt;
&lt;p&gt;this storage is durable, shared, atomic,&lt;br&gt;
and appropriate for production accounting&lt;/p&gt;
&lt;p&gt;In a serverless environment, local filesystem assumptions deserve special attention.&lt;/p&gt;
&lt;p&gt;A file lock only protects concurrent writers if those writers actually share the same durable filesystem.&lt;/p&gt;
&lt;p&gt;Multiple isolated serverless instances may not.&lt;/p&gt;
&lt;p&gt;So this implementation establishes the accounting contract and business logic, but production MYZ writes still need a properly durable storage architecture before we treat the system as authoritative under distributed load.&lt;/p&gt;
&lt;p&gt;That may mean moving the ledger to a transactional database or placing it behind a dedicated single-writer service.&lt;/p&gt;
&lt;p&gt;We also refused to fake a green CI status&lt;/p&gt;
&lt;p&gt;One final engineering lesson came from CI.&lt;/p&gt;
&lt;p&gt;We created a dedicated GitHub Actions workflow for the MYZ ledger contract.&lt;/p&gt;
&lt;p&gt;It includes service tests, route tests, internal-payment tests, and ledger verification.&lt;/p&gt;
&lt;p&gt;But GitHub did not generate a workflow run.&lt;/p&gt;
&lt;p&gt;We even created a dedicated commit touching the workflow itself:&lt;/p&gt;
&lt;p&gt;33180d233dd6abbb558ed58b56371d77eb11faa2&lt;br&gt;
ci(myz): trigger canonical ledger contract&lt;/p&gt;
&lt;p&gt;Still:&lt;/p&gt;
&lt;p&gt;workflow runs = 0&lt;/p&gt;
&lt;p&gt;Other workflows in the repository have execution history.&lt;/p&gt;
&lt;p&gt;So instead of writing:&lt;/p&gt;
&lt;p&gt;CI ✅&lt;/p&gt;
&lt;p&gt;we documented:&lt;/p&gt;
&lt;p&gt;CI execution issue still open&lt;/p&gt;
&lt;p&gt;It sounds minor, but this is one of the habits that matters most in engineering:&lt;/p&gt;
&lt;p&gt;do not convert “we expected this to run” into “it passed.”&lt;/p&gt;
&lt;p&gt;The architecture now looks much simpler&lt;/p&gt;
&lt;p&gt;Before:&lt;/p&gt;
&lt;p&gt;Dashboard MYZ&lt;br&gt;
Payment Dashboard MYZ&lt;br&gt;
Marketplace payment logic&lt;br&gt;
Zorgax payment logic&lt;br&gt;
conversion logic&lt;br&gt;
redemption records&lt;/p&gt;
&lt;p&gt;After:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;            CANONICAL MYZ LEDGER
                     │
    ┌────────────────┼────────────────┐
    │                │                │
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  Marketplace          Zorgax            P2P&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    │                │
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; buyer → seller     user → system&lt;/p&gt;
&lt;p&gt;Business databases still exist.&lt;/p&gt;
&lt;p&gt;They should.&lt;/p&gt;
&lt;p&gt;But they track business state.&lt;/p&gt;
&lt;p&gt;They no longer get to invent their own spendable MYZ balance.&lt;/p&gt;
&lt;p&gt;What I would improve next&lt;/p&gt;
&lt;p&gt;The implementation is merged and deployed, but the architecture still has clear next steps:&lt;/p&gt;
&lt;p&gt;move the ledger to genuinely durable shared storage;&lt;br&gt;
validate double-spend protection across multiple application instances, not only within one process;&lt;br&gt;
fix the GitHub Actions workflow execution;&lt;br&gt;
add persistent purchase intents for even stronger recovery across client idempotency-key loss;&lt;br&gt;
define explicit migration rules for historical MYZ records;&lt;br&gt;
define verified identity mapping between older contributor:&lt;em&gt; accounts and newer marketplace:user:&lt;/em&gt; accounts.&lt;/p&gt;
&lt;p&gt;None of those invalidate the current work.&lt;/p&gt;
&lt;p&gt;They define the boundary between correct application logic and production-grade distributed accounting infrastructure.&lt;/p&gt;
&lt;p&gt;And knowing that boundary is probably the most useful lesson from this refactor.&lt;/p&gt;
&lt;p&gt;The biggest takeaway for me was simple:&lt;/p&gt;
&lt;p&gt;A balance is a derived value.&lt;br&gt;
A ledger is the history that makes the balance trustworthy.&lt;/p&gt;
&lt;p&gt;Once we started treating MYZ that way, Marketplace payments, Zorgax purchases, legacy cleanup, receipts, idempotency, and concurrency all became parts of the same system instead of separate features.&lt;/p&gt;
&lt;p&gt;MyZubster is open source, and we’re building this architecture in public.&lt;/p&gt;
&lt;p&gt;Repository: MyZubster-Ecosystem/myzubster&lt;br&gt;
Main implementation: PR #1286&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27661/we-rebuilt-myz-payments-around-a-canonical-ledger-heres-what-we-learned</guid>
<pubDate>Fri, 18 Sep 2026 16:46:27 +0000</pubDate>
</item>
<item>
<title>XML External Entity (CWE-611): The Bug No Automated Layer Catches — Proven and R</title>
<link>https://coderlegion.com/26138/xml-external-entity-cwe-611-the-bug-no-automated-layer-catches-proven-and-r</link>
<description>&lt;p&gt;One file path. No form, no captured traffic, no automated finding anywhere in the pipeline. Here's the&lt;br&gt;
full story of a real &lt;a rel=&quot;nofollow&quot; href=&quot;https://cwe.mitre.org/data/definitions/611.html&quot;&gt;CWE-611&lt;/a&gt; (XML External Entity)&lt;br&gt;
vulnerability — the first bug in this series where a static rule, a business-logic rule, &lt;em&gt;and&lt;/em&gt; a wired&lt;br&gt;
Active Test probe all come back empty, and every step from proof to report has to be done by hand — all&lt;br&gt;
running locally, with no cloud AI anywhere in the pipeline.&lt;/p&gt;
&lt;h3 id=&quot;the-vulnerable-code&quot;&gt;The vulnerable code&lt;/h3&gt;
&lt;p&gt;The target is a deliberately vulnerable local training app: a hand-rolled XML &quot;entity resolver&quot; — not a&lt;br&gt;
real XML parser, on purpose, so the demo has no native dependencies — that reproduces the classic XXE&lt;br&gt;
pattern any real parser falls into when DTD/external-entity processing isn't explicitly disabled.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function resolveEntities(xml) {
  const entities = {};
  const entityRe = /&amp;lt;!ENTITY\s+(\w+)\s+SYSTEM\s+&quot;([^&quot;]+)&quot;\s*&amp;gt;/g;
  let m;
  while ((m = entityRe.exec(xml))) {
    const [, name, systemId] = m;
    const filePath = systemId.replace(/^file:\/\//, ''); // BUG: no restriction on which file
    try {
      entities[name] = fs.readFileSync(filePath, 'utf8');
    } catch (err) {
      entities[name] = `[[failed to resolve entity ${name}: ${err.message}]]`;
    }
  }
  let resolved = xml;
  for (const [name, value] of Object.entries(entities)) {
    resolved = resolved.split(`&amp;amp;${name};`).join(value);
  }
  return resolved;
}

app.post('/import', (req, res) =&amp;gt; {
  const xml = req.body || '';
  const resolved = resolveEntities(xml);
  const noteMatch = resolved.match(/&amp;lt;note&amp;gt;([\s\S]*?)&amp;lt;\/note&amp;gt;/);
  res.json({ note: noteMatch ? noteMatch[1] : null, resolvedXml: resolved });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every declared &lt;code&gt;SYSTEM&lt;/code&gt; entity gets read off disk with no allowlist at all, and the whole resolved&lt;br&gt;
document — not just whatever lands inside &lt;code&gt;&amp;lt;note&amp;gt;&lt;/code&gt; — comes back in the response. &lt;code&gt;GET /&lt;/code&gt; renders&lt;br&gt;
instructions and the exact &lt;code&gt;curl&lt;/code&gt; command to try; there's no &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; anywhere on this app, which turns&lt;br&gt;
out to matter for how you have to prove this one.&lt;/p&gt;
&lt;h3 id=&quot;step-1-why-the-static-rule-engine-stays-silent-and-that-s-not-a-bug-&quot;&gt;Step 1 — Why the static rule engine stays silent (and that's not a bug)&lt;/h3&gt;
&lt;p&gt;Before assuming &quot;no finding&quot; means &quot;broken,&quot; it's worth tracing exactly why. The platform's XXE rule is&lt;br&gt;
real and precise — it just targets a different library's exact call shape than this app uses:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;parseXml(String)?(…, { noent: true, … })   ← the rule's actual target: libxmljs's opt-in
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This app never calls &lt;code&gt;parseXml&lt;/code&gt; or &lt;code&gt;parseXmlString&lt;/code&gt; at all — it's a regex-based resolver, deliberately, so&lt;br&gt;
the demo needs no native XML dependency. A code-scan pass produces &lt;strong&gt;zero CWE-611 findings&lt;/strong&gt;, exactly as&lt;br&gt;
the rule's own logic predicts. It isn't a clean zero, though: a generic missing-authorization rule flags&lt;br&gt;
both routes (this app, like the rest of the series, has no auth anywhere), landing a real &lt;strong&gt;High&lt;/strong&gt; finding&lt;br&gt;
on &lt;code&gt;POST /import&lt;/code&gt; — a second, entirely separate bug on the same route, worth its own line in a write-up&lt;br&gt;
rather than being lost in the noise.&lt;/p&gt;
&lt;h3 id=&quot;step-2-the-dynamic-business-logic-path-comes-back-empty-too&quot;&gt;Step 2 — The dynamic/business-logic path comes back empty too&lt;/h3&gt;
&lt;p&gt;The path that rescued CSRF and SSRF with at least one automated finding — capture traffic through the&lt;br&gt;
built-in proxy, then check it against the business-logic rules — doesn't help here either. Neither the&lt;br&gt;
live HTTP-interceptor rules nor the business-logic analyzer (both run inside the same &lt;code&gt;proxy&lt;/code&gt; command)&lt;br&gt;
have ever had an entity/DOCTYPE-aware check written for them. A captured request carrying a full XXE&lt;br&gt;
payload produces the same generic header findings as every other request and nothing else.&lt;/p&gt;
&lt;h3 id=&quot;step-3-proving-it-live-with-nothing-but-curl&quot;&gt;Step 3 — Proving it live, with nothing but curl&lt;/h3&gt;
&lt;p&gt;There's no form to click through — &lt;code&gt;GET /&lt;/code&gt; is instructional only — so proof starts at the terminal:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -X POST http://localhost:3008/import -H &quot;Content-Type: application/xml&quot; --data-binary @payload.xml
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;note&quot;:&quot;FLAG{xxe-demo-secret}&quot;,&quot;resolvedXml&quot;:&quot;...&amp;lt;note&amp;gt;FLAG{xxe-demo-secret}&amp;lt;/note&amp;gt;&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's the app's own intended file. The next payload proves it isn't confined to it — pointing the&lt;br&gt;
&lt;code&gt;SYSTEM&lt;/code&gt; identifier at &lt;code&gt;file:///etc/passwd&lt;/code&gt; instead returns that file's contents just as readily. The bug&lt;br&gt;
isn't &quot;leaks one demo secret&quot; — it's unrestricted file disclosure, any file the server process can read.&lt;/p&gt;
&lt;h3 id=&quot;step-4-escalating-from-scratch-because-there-s-nothing-to-seed-from&quot;&gt;Step 4 — Escalating from scratch, because there's nothing to seed from&lt;/h3&gt;
&lt;p&gt;Every prior bug in this series had a captured request to right-click and send into the built-in Repeater.&lt;br&gt;
Not this one — with no form and no proxy-captured traffic, the request has to be typed in by hand from a&lt;br&gt;
blank editor: method, URL, headers, body, all composed manually. A third payload variant makes a point&lt;br&gt;
worth keeping for a write-up:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;?xml version=&quot;1.0&quot;?&amp;gt;
&amp;lt;!DOCTYPE root [ &amp;lt;!ENTITY xxe SYSTEM &quot;file:///etc/passwd&quot;&amp;gt; &amp;lt;!ENTITY host SYSTEM &quot;file:///etc/hostname&quot;&amp;gt; ]&amp;gt;
&amp;lt;note&amp;gt;&amp;amp;xxe;&amp;lt;/note&amp;gt;
&amp;lt;!-- &amp;amp;host; --&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The substitution is a literal string replace over the &lt;em&gt;whole&lt;/em&gt; submitted document, and the response's&lt;br&gt;
&lt;code&gt;resolvedXml&lt;/code&gt; field echoes the &lt;em&gt;entire&lt;/em&gt; resolved document — not just whatever ends up inside &lt;code&gt;&amp;lt;note&amp;gt;&lt;/code&gt;. The&lt;br&gt;
response shows &lt;code&gt;note&lt;/code&gt; still contains only &lt;code&gt;/etc/passwd&lt;/code&gt; (that field's own regex is scoped tightly), while&lt;br&gt;
&lt;code&gt;resolvedXml&lt;/code&gt; contains &lt;strong&gt;both&lt;/strong&gt; files' contents. An attacker doesn't need to route a read through &lt;code&gt;&amp;lt;note&amp;gt;&lt;/code&gt;&lt;br&gt;
at all — anywhere in the document is enough, and several files can leak in a single request.&lt;/p&gt;
&lt;h3 id=&quot;step-5-attempting-the-platform-s-own-active-test-probe-and-finding-there-isn-t-one&quot;&gt;Step 5 — Attempting the platform's own Active Test probe — and finding there isn't one&lt;/h3&gt;
&lt;p&gt;The platform has two dozen wired Active Test probes: SSTI, GraphQL introspection, JWT verification, mass&lt;br&gt;
assignment, CSRF confirmation, and more. I checked the full checklist and the CLI's &lt;code&gt;--probes&lt;/code&gt; alias list&lt;br&gt;
on camera rather than assert it from memory — neither has an XXE entry. Not a blind or limited probe like&lt;br&gt;
SSRF's — genuinely absent. That's the whole phase: confirming an absence is itself the useful result here,&lt;br&gt;
not a consolation prize for a failed run.&lt;/p&gt;
&lt;h3 id=&quot;step-6-filing-the-bug-s-only-finding-by-hand&quot;&gt;Step 6 — Filing the bug's only finding, by hand&lt;/h3&gt;
&lt;p&gt;With nothing in the pipeline ever creating a &lt;code&gt;Finding&lt;/code&gt; object for this bug, the only way it exists in the&lt;br&gt;
platform at all is filing it manually through the Assets hub's &quot;Create finding&quot; tool. Worth knowing before&lt;br&gt;
you hit it yourself: that dialog collects a title, a risk level, and free-text evidence — &lt;strong&gt;no CWE or&lt;br&gt;
reference field at all&lt;/strong&gt; — so the CWE-611 citation has to go into the title text by hand. It's also worth&lt;br&gt;
knowing the resulting finding will always show a &quot;no proof of concept&quot; submission blocker, regardless of&lt;br&gt;
how much evidence you paste in — manual findings never populate the platform's structured proof field,&lt;br&gt;
only free text. Cosmetic, not a real block on a normal report export.&lt;/p&gt;
&lt;h3 id=&quot;step-7-an-unexpected-second-corroboration&quot;&gt;Step 7 — An unexpected second corroboration&lt;/h3&gt;
&lt;p&gt;Running the platform's generic &quot;Analyze response&quot; tool over the &lt;code&gt;/etc/passwd&lt;/code&gt; variant's captured&lt;br&gt;
transaction surfaced something worth keeping: alongside the usual generic header findings, a completely&lt;br&gt;
unrelated rule — a generic filesystem-path-disclosure check — independently flagged the same response,&lt;br&gt;
because it literally contains an absolute path string. Different rule, different reasoning, same&lt;br&gt;
underlying bug — a nice bit of corroborating evidence that cost nothing extra to collect.&lt;/p&gt;
&lt;h3 id=&quot;step-8-a-fully-offline-explanation&quot;&gt;Step 8 — A fully offline explanation&lt;/h3&gt;
&lt;p&gt;With the manually-filed finding's evidence in place, a local LLM generates a plain-language narrative from&lt;br&gt;
it: something like &lt;em&gt;&quot;A hand-rolled XML entity resolver reads an attacker-specified file path from a SYSTEM&lt;br&gt;
entity with no restriction, allowing disclosure of arbitrary files readable by the server process.&quot;&lt;/em&gt; No&lt;br&gt;
network call is made — and this works identically whether a finding was auto-created by a rule or filed by&lt;br&gt;
hand, since the narrator only ever reads the finding's own fields, never its provenance.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-1&quot;&gt;The fix&lt;/h3&gt;
&lt;p&gt;The demo's own bug is a hand-rolled resolver that never should have resolved external entities in the&lt;br&gt;
first place — the fix isn't a configuration flag here, it's refusing to read &lt;code&gt;SYSTEM&lt;/code&gt; identifiers at all:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function resolveEntities(xml) {
  if (/&amp;lt;!DOCTYPE/i.test(xml)) {
    throw new Error('DOCTYPE declarations are not permitted in submitted XML');
  }
  return xml; // no external entities are ever resolved
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a real XML parser, the equivalent is a configuration setting rather than a rewrite: disable DTD&lt;br&gt;
processing and external-entity resolution outright (most real parsers have an explicit&lt;br&gt;
&lt;code&gt;disallow-doctype-decl&lt;/code&gt; / &quot;external entities off&quot; setting — e.g. &lt;code&gt;libxmljs&lt;/code&gt;'s &lt;code&gt;parseXml(xml, { noent:
false, dtdload: false, dtdvalid: false })&lt;/code&gt;, or Node's &lt;code&gt;fast-xml-parser&lt;/code&gt; with &lt;code&gt;processEntities: false&lt;/code&gt;), or&lt;br&gt;
use a parser that doesn't support DTDs at all for untrusted input — verbatim the fix the demo app's own&lt;br&gt;
&lt;code&gt;README.md&lt;/code&gt; suggests.&lt;/p&gt;
&lt;h3 id=&quot;what-s-honestly-not-covered&quot;&gt;What's honestly &lt;em&gt;not&lt;/em&gt; covered&lt;/h3&gt;
&lt;p&gt;This is the starkest version of &quot;not covered&quot; in the whole series: no static match, no dynamic/business-&lt;br&gt;
logic match, no Active Test probe at all. That's not three separate failures — it's one honest gap,&lt;br&gt;
demonstrated on camera at every layer rather than glossed over. The static rule's precision (it targets a&lt;br&gt;
real library's real API, just not this app's) is actually the most reassuring part: it isn't guessing,&lt;br&gt;
it's correctly scoped, and this app simply falls outside that scope by design.&lt;/p&gt;
&lt;h3 id=&quot;try-it-yourself&quot;&gt;Try it yourself&lt;/h3&gt;
&lt;p&gt;The target app and the full step-by-step playbook (every click, every payload, every panel, plus the exact&lt;br&gt;
reasoning behind each &quot;no finding&quot; result) are linked below if you want to reproduce this end-to-end&lt;br&gt;
against your own local copy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This is an intentionally vulnerable local training app.&lt;/strong&gt; Never run these techniques against a&lt;br&gt;
system you don't own or don't have explicit written authorization to test.&lt;/p&gt;
&lt;p&gt;Try it yourself → &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/sendwavehub/scan-target-demo-apps&quot;&gt;https://github.com/sendwavehub/scan-target-demo-apps&lt;/a&gt;&lt;br&gt;
Windows Store &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US&quot;&gt;https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US&lt;/a&gt;&lt;br&gt;
Web Site &lt;a rel=&quot;nofollow&quot; href=&quot;https://Sendwavehub.tech/en/apps/ai-security-studio-4&quot;&gt;https://Sendwavehub.tech/en/apps/ai-security-studio-4&lt;/a&gt;&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/26138/xml-external-entity-cwe-611-the-bug-no-automated-layer-catches-proven-and-r</guid>
<pubDate>Thu, 03 Sep 2026 07:57:14 +0000</pubDate>
</item>
<item>
<title>Server-Side Request Forgery (CWE-918): From Auto-Detected Finding to Live Exploi</title>
<link>https://coderlegion.com/25968/server-side-request-forgery-cwe-918-from-auto-detected-finding-to-live-exploi</link>
<description>&lt;p&gt;A full walkthrough of finding, proving, and reporting a real SSRF bug with a deterministic rule engine, a live form-based exploit, an attempted (and explained) automated probe, and a fully offline local LLM — no cloud AI anywhere in the pipeline.&lt;br&gt;
tags: security, javascript, node, webdev&lt;/p&gt;
&lt;p&gt;One form field. One &quot;internal-only&quot; endpoint that was never supposed to be reachable. Here's the&lt;br&gt;
full story of a real &lt;a rel=&quot;nofollow&quot; href=&quot;https://cwe.mitre.org/data/definitions/918.html&quot;&gt;CWE-918&lt;/a&gt; (Server-Side Request&lt;br&gt;
Forgery) vulnerability, from the moment a rule engine flags it, through a form-only live exploit, to&lt;br&gt;
an honest look at where the platform's own automated probe hits a real limit — all running locally,&lt;br&gt;
with no cloud AI anywhere in the pipeline.&lt;/p&gt;
&lt;h3 id=&quot;the-vulnerable-code-1&quot;&gt;The vulnerable code&lt;/h3&gt;
&lt;p&gt;The target is a deliberately vulnerable local training app: a &quot;URL preview&quot; tool that fetches&lt;br&gt;
whatever URL you give it, server-side.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;app.post('/preview', async (req, res) =&amp;gt; {
  const { url } = req.body;
  if (!url) return res.status(400).json({ error: 'url is required' });
  const upstream = await fetch(url, { redirect: 'follow' }); // BUG: no host/scheme allowlist
  const text = await upstream.text();
  res.json({ url, status: upstream.status, body: text.slice(0, 2000) });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;url&lt;/code&gt; comes straight from the request body and goes straight into &lt;code&gt;fetch()&lt;/code&gt; — no allowlist on scheme&lt;br&gt;
or destination host. The same server also exposes &lt;code&gt;/internal/admin-status&lt;/code&gt;, an &quot;internal-only&quot;&lt;br&gt;
endpoint that isn't linked from any page and simulates something like a cloud-metadata endpoint or an&lt;br&gt;
admin panel. &lt;code&gt;/preview&lt;/code&gt; can reach it anyway — and because the response body gets reflected straight&lt;br&gt;
back to the caller, this isn't blind SSRF: the internal endpoint's own flag comes back in the JSON,&lt;br&gt;
a self-contained proof with nothing but a form submission.&lt;/p&gt;
&lt;h3 id=&quot;step-1-a-deterministic-rule-engine-catches-it-not-an-llm&quot;&gt;Step 1 — A deterministic rule engine catches it, not an LLM&lt;/h3&gt;
&lt;p&gt;Before any AI touches this finding, the same taint-tracking rule that flags command injection and&lt;br&gt;
path traversal also declares an outbound-request sink, and walks the exact shape this bug takes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const { url } = req.body     →  source: untrusted request value
fetch(url, …)                 →  sink: outbound HTTP request
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One static scan raises a &lt;strong&gt;High&lt;/strong&gt;, &lt;strong&gt;CWE-918&lt;/strong&gt; finding automatically — zero manual finding-creation,&lt;br&gt;
zero guesswork. The finding already carries:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Evidence&lt;/strong&gt; — the matched source line, with the exact column of the injection point pinned inside&lt;br&gt;
the tainted expression&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Risk Level&lt;/strong&gt; — High&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security Reference&lt;/strong&gt; — CWE-918 / OWASP A10:2021 (Server-Side Request Forgery)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The rule engine discovers; the LLM only ever explains an &lt;em&gt;already-verified&lt;/em&gt; finding afterward. It&lt;br&gt;
never gets to invent a vulnerability on its own.&lt;/p&gt;
&lt;h3 id=&quot;step-2-proving-it-live-with-zero-extra-tooling&quot;&gt;Step 2 — Proving it live, with zero extra tooling&lt;/h3&gt;
&lt;p&gt;The form field is prefilled &lt;code&gt;http://localhost:3007/internal/admin-status&lt;/code&gt;. Submitting it returns:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;url&quot;: &quot;http://localhost:3007/internal/admin-status&quot;,
  &quot;status&quot;: 200,
  &quot;body&quot;: &quot;{\&quot;internal\&quot;:true,\&quot;message\&quot;:\&quot;This endpoint should only ever be called by trusted internal callers.\&quot;,\&quot;fakeSecret\&quot;:\&quot;FLAG{ssrf-reached-internal-endpoint}\&quot;}&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;body&lt;/code&gt; proves the server, not the browser, reached an endpoint that &quot;isn't linked anywhere and isn't&lt;br&gt;
meant to be reachable from outside this server.&quot; One request/response pair is already enough evidence&lt;br&gt;
on its own — no server access needed to confirm the bug, because the app's own response is externally&lt;br&gt;
observable proof.&lt;/p&gt;
&lt;h3 id=&quot;step-3-escalating-the-payload-and-what-a-rejection-does-and-doesn-t-prove-&quot;&gt;Step 3 — Escalating the payload (and what a rejection does and doesn't prove)&lt;/h3&gt;
&lt;p&gt;The request body is &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;, but the payload itself has no &lt;code&gt;&amp;amp;&lt;/code&gt;/&lt;code&gt;=&lt;/code&gt;&lt;br&gt;
characters, so none of command injection's body-splitting trap applies here — values load and send as&lt;br&gt;
typed:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Payload &lt;/th&gt;&lt;th&gt;What it proves&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;url=http://127.0.0.1:3007/internal/admin-status&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Loopback literal instead of &lt;code&gt;localhost&lt;/code&gt; — same flag comes back&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;url=http://[::1]:3007/internal/admin-status&lt;/code&gt; &lt;/td&gt;&lt;td&gt;IPv6 loopback — a third distinct way into the same endpoint, ruling out a naive hostname-string allowlist&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;url=file:///etc/passwd&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Rejected with a 502 — but by Node's own &lt;code&gt;fetch&lt;/code&gt; (undici), which only supports &lt;code&gt;http&lt;/code&gt;/&lt;code&gt;https&lt;/code&gt;. That's a runtime-level restriction, not an application-level allowlist, and shouldn't be mistaken for one in a write-up&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Three host variations, one important negative result — and the negative result is worth writing up&lt;br&gt;
carefully instead of skipping past it.&lt;/p&gt;
&lt;h3 id=&quot;step-4-attempting-the-platform-s-own-active-test-probe-and-explaining-why-it-can-t-confirm-this-one&quot;&gt;Step 4 — Attempting the platform's own Active Test probe — and explaining why it can't confirm this one&lt;/h3&gt;
&lt;p&gt;The platform does have a real, wired SSRF probe — a GUI checkbox and a CLI flag, backed by an&lt;br&gt;
offline, local out-of-band collaborator (no third-party service, no internet). I set one up and ran&lt;br&gt;
it against &lt;code&gt;/preview&lt;/code&gt;'s &lt;code&gt;url&lt;/code&gt; parameter anyway, on camera, rather than just asserting it wouldn't&lt;br&gt;
work.&lt;/p&gt;
&lt;p&gt;It came back with no finding — and the reason is architectural, not a fluke: the probe always injects&lt;br&gt;
its callback URL via a &lt;strong&gt;GET query string&lt;/strong&gt; (&lt;code&gt;ActiveTestUrls.WithQueryParameter&lt;/code&gt;, sent through a&lt;br&gt;
GET-only client call). This app's vulnerable parameter exists &lt;strong&gt;only&lt;/strong&gt; on &lt;code&gt;POST /preview&lt;/code&gt;'s form&lt;br&gt;
body — there's no &lt;code&gt;GET /preview&lt;/code&gt; route at all, so the probe's request 404s before the server ever&lt;br&gt;
calls &lt;code&gt;fetch()&lt;/code&gt;. No collaborator misconfiguration, no false negative on the app's actual exposure —&lt;br&gt;
just a probe that was built for a different injection channel than this specific endpoint uses.&lt;/p&gt;
&lt;p&gt;Two findings would have been the tidy outcome. One finding plus a documented, code-level reason for&lt;br&gt;
why the second attempt didn't land is the honest one.&lt;/p&gt;
&lt;h3 id=&quot;step-5-a-fully-offline-explanation&quot;&gt;Step 5 — A fully offline explanation&lt;/h3&gt;
&lt;p&gt;With the static evidence and the manual proof both attached to the same finding, a local LLM&lt;br&gt;
generates a plain-language narrative from the structured evidence: something like &lt;em&gt;&quot;Untrusted input&lt;br&gt;
reaches an outbound &lt;code&gt;fetch()&lt;/code&gt; call with no host/scheme allowlist, letting the server be used to reach&lt;br&gt;
its own internal-only endpoints.&quot;&lt;/em&gt; No network call is made. The model reasons over an&lt;br&gt;
already-verified finding; it doesn't discover anything new.&lt;/p&gt;
&lt;h3 id=&quot;step-6-the-fix&quot;&gt;Step 6 — The fix&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const ALLOWED_HOSTS = new Set(['api.trusted-partner.example']);

app.post('/preview', async (req, res) =&amp;gt; {
  const { url } = req.body;
  let target;
  try {
    target = new URL(url);
  } catch {
    return res.status(400).json({ error: 'invalid url' });
  }
  if (!['http:', 'https:'].includes(target.protocol) || !ALLOWED_HOSTS.has(target.hostname)) {
    return res.status(400).json({ error: 'destination not allowed' });
  }
  const upstream = await fetch(target, { redirect: 'manual' }); // no automatic redirect to an unchecked host
  res.json({ url, status: upstream.status });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Allowlist the destination hosts and schemes, resolve and re-validate the actual address before&lt;br&gt;
connecting (reject loopback/link-local/private ranges), turn off automatic redirect-following (or&lt;br&gt;
re-validate the redirect target), and isolate the fetcher's own network egress as defense in depth.&lt;/p&gt;
&lt;h3 id=&quot;what-s-honestly-not-covered-1&quot;&gt;What's honestly &lt;em&gt;not&lt;/em&gt; covered&lt;/h3&gt;
&lt;p&gt;The Active Test tab's SSRF probe is real, wired, and wouldn't be a no-op against every SSRF bug —&lt;br&gt;
just this specific POST-body-only shape. That's a narrower gap than command injection's &quot;no GUI/CLI&lt;br&gt;
entry point at all,&quot; but it's still a gap, and I'd rather show it failing on camera with the reason&lt;br&gt;
attached than imply the platform caught two independent confirmations here when it only caught one.&lt;/p&gt;
&lt;h3 id=&quot;try-it-yourself-1&quot;&gt;Try it yourself&lt;/h3&gt;
&lt;p&gt;The target app and the full step-by-step playbook (every click, every payload, every panel) are&lt;br&gt;
linked below if you want to reproduce this end-to-end against your own local copy.&lt;/p&gt;
&lt;p&gt;Try it yourself → &lt;a rel=&quot;nofollow&quot; href=&quot;http://%20https://github.com/sendwavehub/scan-target-demo-apps&quot;&gt;https://github.com/sendwavehub/scan-target-demo-apps&lt;/a&gt;&lt;br&gt;
Windows Store &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US&quot;&gt;https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US&lt;/a&gt;&lt;br&gt;
Web Site &lt;a rel=&quot;nofollow&quot; href=&quot;https://Sendwavehub.tech/en/apps/ai-security-studio-4&quot;&gt;https://Sendwavehub.tech/en/apps/ai-security-studio-4&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This is an intentionally vulnerable local training app.&lt;/strong&gt; Never run these techniques against a&lt;br&gt;
system you don't own or don't have explicit written authorization to test.&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/25968/server-side-request-forgery-cwe-918-from-auto-detected-finding-to-live-exploi</guid>
<pubDate>Tue, 01 Sep 2026 04:50:58 +0000</pubDate>
</item>
<item>
<title>Path traversal caught two ways — static analysis and a live probe, no cloud AI</title>
<link>https://coderlegion.com/25748/path-traversal-caught-two-ways-static-analysis-and-a-live-probe-no-cloud-ai</link>
<description>&lt;h3 id=&quot;the-bug&quot;&gt;The bug&lt;/h3&gt;
&lt;p&gt;Here's the route from a small Express demo app&lt;br&gt;
(&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/sendwavehub/scan-target-demo-apps&quot;&gt;&lt;code&gt;scan-target-demo-apps/apps/06-path-traversal&lt;/code&gt;&lt;/a&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;app.get('/files', (req, res) =&amp;gt; {
  const name = req.query.name;
  const filePath = path.join(docsDir, name);
  fs.readFile(filePath, 'utf8', (err, data) =&amp;gt; {
    if (err) {
      return res.status(404).json({ error: 'file not found', path: filePath });
    }
    res.type('text/plain').send(data);
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;name&lt;/code&gt; comes straight from the query string and goes straight into &lt;code&gt;path.join()&lt;/code&gt; — no check that the&lt;br&gt;
resolved path actually stays inside &lt;code&gt;docsDir&lt;/code&gt;. &lt;code&gt;path.join('./docs', '../secret.txt')&lt;/code&gt; happily&lt;br&gt;
resolves to a file &lt;em&gt;outside&lt;/em&gt; &lt;code&gt;./docs&lt;/code&gt;. Request &lt;code&gt;/files?name=../secret.txt&lt;/code&gt; and the server reads and&lt;br&gt;
returns whatever's sitting one directory above the one it's supposed to be confined to.&lt;/p&gt;
&lt;p&gt;Classic CWE-22, OWASP A01:2021 (Broken Access Control). Nothing novel in the bug itself — the&lt;br&gt;
interesting part, again, is what caught it and how many independent ways.&lt;/p&gt;
&lt;h3 id=&quot;what-actually-caught-it-twice-independently&quot;&gt;What actually caught it — twice, independently&lt;/h3&gt;
&lt;p&gt;We ran this target through &lt;strong&gt;AI Security Studio&lt;/strong&gt;, an offline security research platform we've been&lt;br&gt;
building. No cloud calls, no code leaves the machine.&lt;/p&gt;
&lt;p&gt;This is the first target in the series where the bug is confirmed by &lt;strong&gt;both&lt;/strong&gt; ends of the pipeline&lt;br&gt;
at once, not just one:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Static — before anyone opens the app.&lt;/strong&gt; The same JavaScript taint rule that flags command&lt;br&gt;
injection also declares a filesystem sink: &lt;code&gt;fs.readFile&lt;/code&gt; / &lt;code&gt;readFileSync&lt;/code&gt; / &lt;code&gt;writeFile&lt;/code&gt; /&lt;br&gt;
&lt;code&gt;.sendFile(&lt;/code&gt;, etc. It traces &lt;code&gt;req.query.name&lt;/code&gt; → &lt;code&gt;path.join(docsDir, name)&lt;/code&gt; → &lt;code&gt;fs.readFile(filePath,
…)&lt;/code&gt; entirely inside &lt;code&gt;server.js&lt;/code&gt;, and auto-creates a finding — Risk: High, CWE-22, OWASP A01:2021 —&lt;br&gt;
with zero manual steps. This scores &lt;em&gt;reachability&lt;/em&gt;: untrusted input reaches a filesystem sink.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Dynamic — a live, automated probe.&lt;/strong&gt; Unlike some other bug classes in this series where the&lt;br&gt;
active-probe side isn't wired up yet, path traversal has a real, on-by-default probe&lt;br&gt;
(&lt;code&gt;PathTraversalProbeAdapter&lt;/code&gt;, &lt;code&gt;ProbeSafetyTier.ReadOnly&lt;/code&gt;). Point it at &lt;code&gt;/files&lt;/code&gt; with parameter &lt;code&gt;name&lt;/code&gt;&lt;br&gt;
and run it: it comes back with a &lt;em&gt;Critical&lt;/em&gt; finding, &lt;code&gt;Confidence: Likely&lt;/code&gt;, and — the part that&lt;br&gt;
usually takes a human write-up — an attached, ready-to-run &lt;code&gt;curl&lt;/code&gt; reproduction already generated. This&lt;br&gt;
scores &lt;em&gt;confirmed exploitation&lt;/em&gt;, which is why it lands at a higher risk level than the static-only&lt;br&gt;
finding for the same root cause. Different numbers for the same bug at different pipeline stages is&lt;br&gt;
expected, not a bug in the platform.&lt;/p&gt;
&lt;p&gt;Proving it by hand needs nothing but a browser, too — no tooling required:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET /files?name=../secret.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;returns the contents of &lt;code&gt;secret.txt&lt;/code&gt;, a file that lives one directory above the app's intended&lt;br&gt;
&lt;code&gt;./docs&lt;/code&gt; root and was never meant to be reachable through &lt;code&gt;/files&lt;/code&gt; at all.&lt;/p&gt;
&lt;h3 id=&quot;why-offline-is-the-actual-point&quot;&gt;Why &quot;offline&quot; is the actual point&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;You can point this at unreleased/internal code without a vendor's cloud pipeline ever touching it.&lt;/li&gt;
&lt;li&gt;The rule engine and the active-test probe — not the LLM — do the actual finding and the actual&lt;br&gt;
exploitation. Results are reproducible and don't drift between runs.&lt;/li&gt;
&lt;li&gt;The local LLM's job shrinks to something it's actually reliable at: turning the already-structured&lt;br&gt;
evidence (the tainted source line, the curl proof, the CWE/OWASP references) into a plain-language&lt;br&gt;
explanation — &quot;untrusted input reaches a filesystem read via unsanitized &lt;code&gt;path.join&lt;/code&gt;, allowing&lt;br&gt;
arbitrary file disclosure outside the intended directory.&quot; No raw request/response bodies, no raw&lt;br&gt;
source files, ever get handed to the model directly.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;try-it-yourself-2&quot;&gt;Try it yourself&lt;/h3&gt;
&lt;p&gt;The demo app is intentionally vulnerable and open source — safe to point any scanner at, including&lt;br&gt;
this one:&lt;/p&gt;
&lt;p&gt;Try it yourself → &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/sendwavehub/scan-target-demo-apps&quot;&gt;https://github.com/sendwavehub/scan-target-demo-apps&lt;/a&gt;&lt;br&gt;
Windows Store &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US&quot;&gt;https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US&lt;/a&gt;&lt;br&gt;
Web Site &lt;a rel=&quot;nofollow&quot; href=&quot;https://Sendwavehub.tech/en/apps/ai-security-studio-4&quot;&gt;https://Sendwavehub.tech/en/apps/ai-security-studio-4&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Built for security researchers, pentesters, and AppSec teams doing work they're authorized to do.&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/25748/path-traversal-caught-two-ways-static-analysis-and-a-live-probe-no-cloud-ai</guid>
<pubDate>Sat, 29 Aug 2026 08:48:09 +0000</pubDate>
</item>
<item>
<title>Command Injection (CWE-78): From Auto-Detected Finding to Live Exploit to Fixed</title>
<link>https://coderlegion.com/25539/command-injection-cwe-78-from-auto-detected-finding-to-live-exploit-to-fixed</link>
<description>&lt;p&gt;One tainted variable. One shell on the host. Here's the full story of a real&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://cwe.mitre.org/data/definitions/78.html&quot;&gt;CWE-78&lt;/a&gt; (OS Command&lt;br&gt;
Injection) vulnerability, from the moment a rule engine flags it to a&lt;br&gt;
disclosure-ready report — all running locally, with no cloud AI anywhere in&lt;br&gt;
the pipeline.&lt;/p&gt;
&lt;h3 id=&quot;the-vulnerable-code-2&quot;&gt;The vulnerable code&lt;/h3&gt;
&lt;p&gt;The target is a deliberately vulnerable local training app: a tiny &quot;network&lt;br&gt;
diagnostics&quot; tool that pings a host you give it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;app.post('/ping', (req, res) =&amp;gt; {
  const host = req.body.host || '127.0.0.1';
  const cmd = `ping -c 1 ${host}`; // BUG: unsanitized string concatenation
  exec(cmd, { timeout: 5000 }, (err, stdout, stderr) =&amp;gt; {
    res.json({ cmd, stdout, stderr, error: err ? err.message : null });
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No validation. No escaping. No argument array — just user input flowing&lt;br&gt;
straight into a shell. And because the JSON response echoes back the exact&lt;br&gt;
&lt;code&gt;cmd&lt;/code&gt; string the server built, the vulnerable string is visible in &lt;em&gt;every&lt;/em&gt;&lt;br&gt;
response, not just the source — one of the cleanest bugs you'll ever capture&lt;br&gt;
airtight evidence from.&lt;/p&gt;
&lt;h3 id=&quot;step-1-a-deterministic-rule-engine-catches-it-not-an-llm-1&quot;&gt;Step 1 — A deterministic rule engine catches it, not an LLM&lt;/h3&gt;
&lt;p&gt;Before any AI touches this finding, a taint-tracking static analysis rule&lt;br&gt;
walks the exact shape this bug takes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const host = req.body.host …    →  source: untrusted request value
const cmd  = `ping -c 1 ${host}`  →  assignment: taint propagates through host → cmd
exec(cmd, …)                     →  sink: child_process.exec
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One static scan raises a &lt;strong&gt;Critical&lt;/strong&gt;, &lt;strong&gt;CWE-78&lt;/strong&gt; finding automatically —&lt;br&gt;
zero manual finding-creation, zero guesswork. The finding already carries:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Evidence&lt;/strong&gt; — the matched source line, with the exact column of the&lt;br&gt;
injection point pinned inside the tainted expression&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Risk Level&lt;/strong&gt; — Critical&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security Reference&lt;/strong&gt; — CWE-78 / OWASP A03:2021 (Injection)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The rule engine discovers; the LLM only ever explains an &lt;em&gt;already-verified&lt;/em&gt;&lt;br&gt;
finding afterward. It never gets to invent a vulnerability on its own.&lt;/p&gt;
&lt;h3 id=&quot;step-2-proving-it-live-with-zero-extra-tooling-1&quot;&gt;Step 2 — Proving it live, with zero extra tooling&lt;/h3&gt;
&lt;p&gt;The form field is prefilled &lt;code&gt;127.0.0.1; id&lt;/code&gt;. Submitting it returns:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;cmd&quot;: &quot;ping -c 1 127.0.0.1; id&quot;,
  &quot;stdout&quot;: &quot;PING 127.0.0.1 ...\nuid=501(demo) gid=20(staff) groups=...\n&quot;,
  &quot;stderr&quot;: &quot;&quot;,
  &quot;error&quot;: null
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cmd&lt;/code&gt; proves the server built exactly the string you'd expect from unescaped&lt;br&gt;
concatenation. &lt;code&gt;stdout&lt;/code&gt; proves it further — after the ping output, a&lt;br&gt;
&lt;code&gt;uid=…gid=…&lt;/code&gt; line: the output of &lt;code&gt;id&lt;/code&gt;, a command nobody asked the app to&lt;br&gt;
run. One request/response pair is already enough evidence on its own — no&lt;br&gt;
server access needed to confirm the bug, because the app's own response is&lt;br&gt;
externally observable proof.&lt;/p&gt;
&lt;h3 id=&quot;step-3-varying-the-payload-and-why-encoding-matters-&quot;&gt;Step 3 — Varying the payload (and why encoding matters)&lt;/h3&gt;
&lt;p&gt;A quick gotcha worth calling out for anyone testing this class of bug by&lt;br&gt;
hand: the request's &lt;code&gt;Content-Type&lt;/code&gt; is &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;,&lt;br&gt;
so a literal &lt;code&gt;host=127.0.0.1 &amp;amp;&amp;amp; whoami&lt;/code&gt; gets split by the form parser on the&lt;br&gt;
raw &lt;code&gt;&amp;amp;&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; the payload ever reaches &lt;code&gt;host&lt;/code&gt; — the metacharacter never&lt;br&gt;
survives. Percent-encode it instead:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Payload &lt;/th&gt;&lt;th&gt;Encoded body&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;127.0.0.1 &amp;amp;&amp;amp; whoami&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;host=127.0.0.1%20%26%26%20whoami&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;127.0.0.1 \| uname -a&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;host=127.0.0.1%20%7Cuname%20-a&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;`id`&lt;/code&gt; (substitution) &lt;/td&gt;&lt;td&gt;&lt;code&gt;host=%60id%60&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Four independent shell metacharacters (&lt;code&gt;;&lt;/code&gt;, &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;|&lt;/code&gt;, &lt;code&gt;`&lt;/code&gt;), four&lt;br&gt;
independent proofs that this reaches a real shell — not one lucky payload.&lt;/p&gt;
&lt;h3 id=&quot;step-4-a-fully-offline-explanation&quot;&gt;Step 4 — A fully offline explanation&lt;/h3&gt;
&lt;p&gt;With the static evidence and the live proof both attached to the same&lt;br&gt;
finding, a local LLM generates a plain-language narrative from the&lt;br&gt;
structured evidence — something like &lt;em&gt;&quot;Untrusted input reaches&lt;br&gt;
&lt;code&gt;child_process.exec&lt;/code&gt; via string concatenation — full remote code&lt;br&gt;
execution.&quot;&lt;/em&gt; No network call is made. The model reasons over an&lt;br&gt;
already-verified finding; it doesn't discover anything new.&lt;/p&gt;
&lt;h3 id=&quot;step-5-the-fix&quot;&gt;Step 5 — The fix&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const { execFile } = require('node:child_process');

app.post('/ping', (req, res) =&amp;gt; {
  const host = req.body.host || '127.0.0.1';
  execFile('ping', ['-c', '1', host], { timeout: 5000 }, (err, stdout, stderr) =&amp;gt; {
    res.json({ stdout, stderr, error: err ? err.message : null });
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;execFile&lt;/code&gt; with an argument array means there's no shell to interpret in the&lt;br&gt;
first place — &lt;code&gt;host&lt;/code&gt; is passed as a literal argument, never concatenated&lt;br&gt;
into a command string. Add strict input validation on &lt;code&gt;host&lt;/code&gt; (e.g. an&lt;br&gt;
IP/hostname allow-list pattern) as defense in depth.&lt;/p&gt;
&lt;h3 id=&quot;what-s-honestly-not-covered-2&quot;&gt;What's honestly &lt;em&gt;not&lt;/em&gt; covered&lt;/h3&gt;
&lt;p&gt;Two active-exploitation probes for this bug class exist in the platform's&lt;br&gt;
engine, but neither is currently wired to a GUI checkbox or CLI flag — so&lt;br&gt;
dynamic confirmation via automated active testing isn't possible today for&lt;br&gt;
this specific bug. That's exactly why this walkthrough proves the exploit&lt;br&gt;
by hand instead of relying on tooling that isn't there yet. Calling out&lt;br&gt;
tooling gaps honestly matters more to me than pretending coverage is&lt;br&gt;
complete.&lt;/p&gt;
&lt;h3 id=&quot;try-it-yourself-3&quot;&gt;Try it yourself&lt;/h3&gt;
&lt;p&gt;The target app and the full step-by-step playbook (every click, every&lt;br&gt;
payload, every panel) are linked below if you want to reproduce this&lt;br&gt;
end-to-end against your own local copy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This is an intentionally vulnerable local training app.&lt;/strong&gt; Never run these&lt;br&gt;
techniques against a system you don't own or don't have explicit written&lt;br&gt;
authorization to test.&lt;/p&gt;
&lt;p&gt;Try it yourself → &lt;a rel=&quot;nofollow&quot; href=&quot;http://github.com/sendwavehub/scan-target-demo-apps&quot;&gt;https://github.com/sendwavehub/scan-target-demo-apps&lt;/a&gt;&lt;br&gt;
Windows Store &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US&quot;&gt;https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US&lt;/a&gt;&lt;br&gt;
Web Site &lt;a rel=&quot;nofollow&quot; href=&quot;https://Sendwavehub.tech/en/apps/ai-security-studio-4&quot;&gt;https://Sendwavehub.tech/en/apps/ai-security-studio-4&lt;/a&gt;&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/25539/command-injection-cwe-78-from-auto-detected-finding-to-live-exploit-to-fixed</guid>
<pubDate>Thu, 27 Aug 2026 09:57:50 +0000</pubDate>
</item>
<item>
<title>The HTTP QUERY method (RFC 10008) is here — and caching it correctly is harder than it looks</title>
<link>https://coderlegion.com/24910/the-http-query-method-rfc-10008-is-here-and-caching-it-correctly-is-harder-than-it-looks</link>
<description>&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ld3ixdq900l705ew8k3t.gif&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;tl-dr&quot;&gt;TL;DR&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;HTTP got a new method: &lt;strong&gt;QUERY&lt;/strong&gt; (RFC 10008, June 2026). It's a &lt;strong&gt;safe, idempotent, cacheable request that carries a body&lt;/strong&gt; — &quot;GET with a body.&quot;&lt;/li&gt;
&lt;li&gt;It fixes a decade-old pain: complex reads (search/filter/graph queries) that don't fit in a URL, without abusing &lt;code&gt;POST&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The subtle part is &lt;strong&gt;caching&lt;/strong&gt;: the RFC requires the &lt;strong&gt;request body&lt;/strong&gt; to be part of the cache key. Get it wrong and one client gets another client's response.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/hardik-goel/http-queryable&quot;&gt;&lt;code&gt;http-queryable&lt;/code&gt;&lt;/a&gt; is a small library that does this correctly for Express, Fastify, raw &lt;code&gt;http&lt;/code&gt;, and the browser.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-is-the-query-method-&quot;&gt;What is the QUERY method?&lt;/h3&gt;
&lt;p&gt;For decades HTTP forced a bad choice for &quot;reads with a big input&quot;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GET&lt;/code&gt; — safe, idempotent, cacheable, but &lt;strong&gt;no body&lt;/strong&gt;. Cram everything into the URL and hit length limits.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST&lt;/code&gt; — has a body, but it's &lt;strong&gt;unsafe and non-idempotent&lt;/strong&gt;. Caches and proxies won't treat it as a read, and you can't safely retry.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;QUERY&lt;/code&gt; is the missing middle: &lt;strong&gt;a body like POST, but the semantics of GET&lt;/strong&gt; — safe, idempotent, and cacheable.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;QUERY /search HTTP/1.1
Content-Type: application/json

{ &quot;q&quot;: &quot;cats&quot;, &quot;filters&quot;: { &quot;color&quot;: &quot;black&quot; } }
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-catch-nobody-talks-about-caching&quot;&gt;The catch nobody talks about: caching&lt;/h3&gt;
&lt;p&gt;RFC 10008 §2.7 is explicit: &lt;strong&gt;the cache key must incorporate the request content.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Shared HTTP caches have always keyed on &lt;strong&gt;method + URL&lt;/strong&gt;. With QUERY, many &lt;em&gt;different&lt;/em&gt; bodies hit the &lt;em&gt;same&lt;/em&gt; URL:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;QUERY /search  { &quot;q&quot;: &quot;cats&quot; }   -&amp;gt; cats
QUERY /search  { &quot;q&quot;: &quot;dogs&quot; }   -&amp;gt; dogs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A method+URL cache would serve the &lt;strong&gt;cats&lt;/strong&gt; response to the **dctness and security bug — the RFC's Security Considerationscalls it out.&lt;/p&gt;
&lt;h3 id=&quot;doing-it-right-conservative-body-normalization&quot;&gt;Doing it right: conservative body normalization&lt;/h3&gt;
&lt;p&gt;You want two things in tension:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;{&quot;a&quot;:1,&quot;b&quot;:2}&lt;/code&gt; and &lt;code&gt;{ &quot;b&quot;:2, &quot;a&quot;:1 }&lt;/code&gt; mean the same thing → &lt;strong&gt;same key&lt;/strong&gt; (a hit).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;{&quot;q&quot;:&quot;cats&quot;}&lt;/code&gt; and &lt;code&gt;{&quot;q&quot;:&quot;dogs&quot;}&lt;/code&gt; differ → *&lt;em&gt;different keys&lt;/em&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The key insight is an &lt;strong&gt;asymmetry&lt;/strong&gt;: a false &lt;strong&gt;miss&lt;/strong&gt; is harmle&lt;strong&gt; is a bug (wrong data served). So every normalization must be&lt;/strong&gt;provably meaning-preserving*&lt;em&gt;, and when in doubt you normalize &lt;/em&gt;less*.&lt;/p&gt;
&lt;p&gt;Safe for JSON: whitespace, object key order, string escape forms. NOT safe: merging numeric literals (&lt;code&gt;JSON.parse(&quot;9007199254740993&quot;)&lt;/code&gt; returns &lt;code&gt;...992&lt;/code&gt; — a&lt;br&gt;
silent collision), guessing on duplicate keys, or normalizing ctand.&lt;/p&gt;
&lt;h3 id=&quot;show-me-the-code&quot;&gt;Show me the code&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;import express from &quot;express&quot;;
import { queryable, QueryCache } from &quot;http-queryable/express&quot;;

const app = express();
app.use(queryable({ cache: new QueryCache() }));
app.query(&quot;/search&quot;, (req, res) =&amp;gt; res.json(search(req.body)));
app.listen(3000);

curl -X QUERY /search -d '{&quot;q&quot;:&quot;cats&quot;}'      # MISS -&amp;gt; cats
curl -X QUERY /search -d '{ &quot;q&quot; : &quot;cats&quot; }'  # HIT  (same meaning)
curl -X QUERY /search -d '{&quot;q&quot;:&quot;dogs&quot;}'      # MISS -&amp;gt; dogs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That third line is the whole point: a different body gets the cr a stale hit.&lt;/p&gt;
&lt;h3 id=&quot;it-handles-the-rest-too&quot;&gt;It handles the rest too&lt;/h3&gt;
&lt;p&gt;Accept-Query negotiation, Content-Location &quot;switch to GET&quot; flow CORS-safelisted), an isomorphic client with safe auto-retry,and Fastify + raw http adapters over the same core.&lt;/p&gt;
&lt;h3 id=&quot;try-it-1&quot;&gt;Try it&lt;/h3&gt;
&lt;p&gt;Node &amp;gt;= 22 (where &lt;code&gt;QUERY&lt;/code&gt; lands in &lt;code&gt;http.METHODS&lt;/code&gt;).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install http-queryable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Repo + docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/hardik-goel/http-queryable&quot;&gt;https://github.com/hardik-goel/http-queryable&lt;/a&gt; — stars and edge-case bug reports welcome.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24910/the-http-query-method-rfc-10008-is-here-and-caching-it-correctly-is-harder-than-it-looks</guid>
<pubDate>Wed, 19 Aug 2026 09:51:44 +0000</pubDate>
</item>
<item>
<title>Build an Electron app with local data storage — no SQLite bindings, no server</title>
<link>https://coderlegion.com/24329/build-an-electron-app-with-local-data-storage-no-sqlite-bindings-no-server</link>
<description>&lt;p&gt;If you've shipped an Electron app that persists structured data, you've probably hit the wall at some point. You need something more than a flat JSON file, but a full database server is absurd for a desktop app. So you reach for better-sqlite3 — and then spend the next hour fighting native bindings.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-electron-storage-problem&quot;&gt;The Electron storage problem&lt;/h3&gt;
&lt;p&gt;Electron bundles its own version of Node.js. That version almost never matches the one on your machine. Native Node modules — the ones that compile C++ bindings — need to be recompiled specifically for the Electron runtime, using &lt;code&gt;electron-rebuild&lt;/code&gt; or equivalent tooling.&lt;/p&gt;
&lt;p&gt;In practice this means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Every Electron update potentially breaks your native dependencies&lt;/li&gt;
&lt;li&gt;CI pipelines need platform-specific build steps for Windows, macOS and Linux&lt;/li&gt;
&lt;li&gt;Code signing and notarization on macOS gets complicated by native binaries&lt;/li&gt;
&lt;li&gt;End users on some machines encounter install failures you can't reproduce locally&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;better-sqlite3 is an excellent library. But it is a native module, and native modules in Electron are a tax you pay forever.&lt;/p&gt;
&lt;p&gt;The alternatives people reach for have their own problems. lowdb and lokijs keep everything in memory and flush to disk — fine for small datasets, but you lose durability on crash and memory usage grows with your data. nedb hasn't been maintained in years. A remote database is obviously wrong for a local desktop app.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-you-actually-need&quot;&gt;What you actually need&lt;/h3&gt;
&lt;p&gt;For most Electron apps, the storage requirements are modest but specific:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Structured documents, not just key-value pairs&lt;/li&gt;
&lt;li&gt;Queries that go beyond &quot;give me everything&quot;&lt;/li&gt;
&lt;li&gt;Writes that survive a crash&lt;/li&gt;
&lt;li&gt;Zero native binaries — pure JavaScript, works everywhere Electron runs&lt;/li&gt;
&lt;li&gt;No server process running alongside your app&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That's exactly the gap pocket-db is designed to fill. It's a single-file embedded document store — the SQLite model applied to JSON documents, with a MongoDB-style API. Pure TypeScript, shipped as both ESM and CommonJS. No native bindings. No optional dependencies.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;building-the-example-app&quot;&gt;Building the example app&lt;/h3&gt;
&lt;p&gt;Let's build a small note-taking app: create, list, search and delete notes. Simple enough to follow in full, realistic enough to show the pattern.&lt;/p&gt;
&lt;h4 id=&quot;setup&quot;&gt;Setup&lt;/h4&gt;
&lt;p&gt;We'll use &lt;a rel=&quot;nofollow&quot; href=&quot;https://electron-vite.org&quot;&gt;electron-vite&lt;/a&gt;, which scaffolds the &lt;code&gt;src/main&lt;/code&gt; / &lt;code&gt;src/preload&lt;/code&gt; / &lt;code&gt;src/renderer&lt;/code&gt; layout used below, with TypeScript and React ready to go:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm create @quick-start/electron@latest my-notes -- --template react-ts
cd my-notes
npm install @axfab/pocket-db
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;code&gt;electron-rebuild&lt;/code&gt;. No postinstall scripts. That's the point.&lt;/p&gt;
&lt;h4 id=&quot;initialising-the-database&quot;&gt;Initialising the database&lt;/h4&gt;
&lt;p&gt;In Electron, database access belongs in the main process. You have access to the filesystem there, and it keeps your renderer process clean. Use &lt;code&gt;app.getPath('userData')&lt;/code&gt; — Electron's standard location for app data, persisted across updates.&lt;/p&gt;
&lt;p&gt;One thing to know: Electron creates the &lt;code&gt;userData&lt;/code&gt; directory lazily, so on a first launch it may not exist yet — and &lt;code&gt;open()&lt;/code&gt; does not create parent directories. Create it before opening:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/main/db.ts
import { app } from 'electron'
import { open } from '@axfab/pocket-db'
import { mkdirSync } from 'node:fs'
import path from 'node:path'

const dataDir = app.getPath('userData')
mkdirSync(dataDir, { recursive: true }) // may not exist on first launch

export const db = open({ path: path.join(dataDir, 'notes.pdb') })
export const notes = db.collection('notes')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One file, one call. The database is created if it doesn't exist. No migrations, no schema definition.&lt;/p&gt;
&lt;h4 id=&quot;exposing-database-operations-via-ipc&quot;&gt;Exposing database operations via IPC&lt;/h4&gt;
&lt;p&gt;Electron's security model means the renderer can't touch Node APIs directly. You expose operations through IPC handlers in the main process and call them via &lt;code&gt;contextBridge&lt;/code&gt; in the preload script.&lt;/p&gt;
&lt;p&gt;Search deserves a moment of care: the search term comes from user input, and &lt;code&gt;$regex&lt;/code&gt; compiles it as a regular expression. A term containing &lt;code&gt;(&lt;/code&gt; or &lt;code&gt;[&lt;/code&gt; would throw, and a raw pattern is case-sensitive. Escape the input and pass &lt;code&gt;$options: 'i'&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/main/index.ts
import { app, BrowserWindow, ipcMain } from 'electron'
import { db, notes } from './db'

/** Escapes regex metacharacters so user input is matched literally. */
const escapeRegex = (term: string) =&amp;gt;
  term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&amp;amp;')

ipcMain.handle('notes:create', (_event, title: string, body: string) =&amp;gt; {
  return notes.insertOne({ title, body, createdAt: Date.now() })
})

ipcMain.handle('notes:list', () =&amp;gt; {
  return notes.find().sort({ createdAt: -1 }).toArray()
})

ipcMain.handle('notes:search', (_event, term: string) =&amp;gt; {
  const pattern = escapeRegex(term)
  return notes.find({
    $or: [
      { title: { $regex: pattern, $options: 'i' } },
      { body:  { $regex: pattern, $options: 'i' } }
    ]
  }).toArray()
})

ipcMain.handle('notes:delete', (_event, id: string) =&amp;gt; {
  return notes.deleteOne(id)
})

app.on('before-quit', () =&amp;gt; db.close())
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/preload/index.ts
// Note: keep the preload compiled to CommonJS (electron-vite's default) —
// sandboxed renderers don't load ESM preload scripts.
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('notes', {
  create:  (title: string, body: string) =&amp;gt; ipcRenderer.invoke('notes:create', title, body),
  list:    ()           =&amp;gt; ipcRenderer.invoke('notes:list'),
  search:  (term: string) =&amp;gt; ipcRenderer.invoke('notes:search', term),
  delete:  (id: string) =&amp;gt; ipcRenderer.invoke('notes:delete', id),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;using-it-in-the-renderer&quot;&gt;Using it in the renderer&lt;/h4&gt;
&lt;p&gt;From the renderer, the API is just async function calls:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/renderer/src/App.tsx
const handleCreate = async () =&amp;gt; {
  await window.notes.create(title, body)
  setNotes(await window.notes.list())
}

const handleSearch = async (term: string) =&amp;gt; {
  const results = await window.notes.search(term)
  setNotes(results)
}

const handleDelete = async (id: string) =&amp;gt; {
  await window.notes.delete(id)
  setNotes(await window.notes.list())
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No database logic in the renderer. No connection strings. No async initialisation to wait for. The main process owns the database, the renderer just calls handlers.&lt;/p&gt;
&lt;h4 id=&quot;where-indexes-help-and-where-they-don-t&quot;&gt;Where indexes help — and where they don't&lt;/h4&gt;
&lt;p&gt;Two things to know about pocket-db's query planner in V1: &lt;code&gt;$regex&lt;/code&gt; is never index-assisted (it's always evaluated against the documents), and &lt;code&gt;$or&lt;/code&gt; queries bypass index planning entirely. So the regex search above is a full collection scan — perfectly fine for a notes app, but don't expect an index to speed it up.&lt;/p&gt;
&lt;p&gt;Indexes shine on equality and range lookups. Say notes belong to notebooks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// run once at startup, safe to call repeatedly
notes.createIndex('notebook', { type: 'string' })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now a query like &lt;code&gt;notes.find({ notebook: 'work' })&lt;/code&gt; reads only the matching documents instead of scanning the collection — the planner picks the index up automatically, no change needed in your query code. The same goes for a number index on &lt;code&gt;createdAt&lt;/code&gt; if you query by date range.&lt;/p&gt;
&lt;h4 id=&quot;periodic-compaction&quot;&gt;Periodic compaction&lt;/h4&gt;
&lt;p&gt;The append-only log accumulates dead records as notes are edited and deleted. Schedule compaction when the app is idle:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;app.on('browser-window-blur', () =&amp;gt; {
  db.compact()
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;compact()&lt;/code&gt; is fast and synchronous — it rewrites the file in a single forward pass. Calling it when the window loses focus means it runs during natural pauses without affecting responsiveness.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;packaging-and-distribution&quot;&gt;Packaging and distribution&lt;/h3&gt;
&lt;p&gt;This is where the native module problem usually surfaces. With pocket-db, there's nothing to rebuild. The electron-vite scaffold already wires up electron-builder; its config just works as-is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# electron-builder.yml (generated by the scaffold)
mac:
  target: dmg
win:
  target: nsis
linux:
  target: AppImage
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;code&gt;electron-rebuild&lt;/code&gt; in your build pipeline. No platform-specific post-install hooks. The same config builds cleanly on all three platforms.&lt;/p&gt;
&lt;p&gt;If you're using GitHub Actions to build releases:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/release.yml
- name: Build
  run: npm run build &amp;amp;&amp;amp; npx electron-builder
  # No electron-rebuild step needed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One fewer thing to maintain.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-about-data-migration-&quot;&gt;What about data migration?&lt;/h3&gt;
&lt;p&gt;pocket-db is schemaless — documents in the same collection can have different shapes. That means additive changes (adding a new field) require nothing: new documents have the field, old ones don't, and &lt;code&gt;$exists&lt;/code&gt; lets you query for either.&lt;/p&gt;
&lt;p&gt;For breaking changes, a one-time migration on startup is straightforward. Keep a version marker in a metadata collection — and remember that on a fresh database the marker doesn't exist yet:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const schema = db.collection('_meta')
const marker = schema.findOne({ key: 'version' })
const version = typeof marker?.value === 'number' ? marker.value : 0

if (version &amp;lt; 2) {
  // run migration
  notes.updateMany({}, { $set: { archived: false } })

  if (marker) {
    schema.replaceOne(marker._id as string, { key: 'version', value: 2 })
  } else {
    schema.insertOne({ key: 'version', value: 2 })
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the &lt;code&gt;replaceOne(marker._id, …)&lt;/code&gt; call: the first argument is the document &lt;strong&gt;id&lt;/strong&gt;. (Passing a full document as the only argument is a separate overload that replaces that document by itself — not what you want here.)&lt;/p&gt;
&lt;p&gt;No migration framework needed for most desktop app scenarios.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-trade-offs-to-know&quot;&gt;The trade-offs to know&lt;/h3&gt;
&lt;p&gt;pocket-db is designed for single-process use. An Electron app fits that model perfectly — the main process is the only writer, and that's exactly the intended use case.&lt;/p&gt;
&lt;p&gt;It is not the right choice if your Electron app spawns multiple Node worker processes that all need to write to the same database simultaneously. It is also not the right choice if you're storing hundreds of thousands of documents and need complex aggregation — at that scale, an actual database server starts making sense even in a desktop context.&lt;/p&gt;
&lt;p&gt;For the common case — a desktop app storing user data, preferences, history, cached API responses, local task lists — it fits well.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;getting-started&quot;&gt;Getting started&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @axfab/pocket-db
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Full documentation at &lt;a rel=&quot;nofollow&quot; href=&quot;https://pocket-db.axfab.net&quot;&gt;pocket-db.axfab.net&lt;/a&gt;. Source and issues at &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/axfab/pocket-db&quot;&gt;github.com/axfab/pocket-db&lt;/a&gt;. The complete example app from this article is in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/AxFab/pocket-example/tree/develop/01_electron&quot;&gt;repository of exemple&lt;/a&gt;, tests included.&lt;/p&gt;
&lt;p&gt;If you've been putting up with native binding headaches in Electron, give it a try and let me know how it goes.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;pocket-db is MIT licensed.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24329/build-an-electron-app-with-local-data-storage-no-sqlite-bindings-no-server</guid>
<pubDate>Sun, 09 Aug 2026 10:38:03 +0000</pubDate>
</item>
<item>
<title>pocket-db vs lowdb vs LokiJS: an honest embedded database benchmark</title>
<link>https://coderlegion.com/24328/pocket-db-vs-lowdb-vs-lokijs-an-honest-embedded-database-benchmark</link>
<description>&lt;p&gt;If you've ever built a CLI tool, an Electron app, or a local-first prototype in Node.js, you've faced the same question: &lt;em&gt;where do I store structured data without spinning up a server?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The usual suspects are well known — lowdb, LokiJS, nedb, better-sqlite3. I recently built &lt;strong&gt;pocket-db&lt;/strong&gt;, a new embedded document store for Node.js.&lt;/p&gt;
&lt;p&gt;I wanted a database solution that offered me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a single-file database&lt;/li&gt;
&lt;li&gt;no native compilation&lt;/li&gt;
&lt;li&gt;Mongo-like API&lt;/li&gt;
&lt;li&gt;easy embedding in CLI or Electron apps&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I wanted to know honestly where it stands. So I ran a benchmark across 10 common operations on a collection of 1,000 documents.&lt;br&gt;
Here's what I found — not shying away from the unflattering parts.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-contenders&quot;&gt;The contenders&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;pocket-db&lt;/strong&gt; — append-only single-file document store, MongoDB-like API, indexes in memory&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;sqlite (in-memory)&lt;/strong&gt; — better-sqlite3 with an in-memory database, the fastest SQLite configuration&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;sqlite (file)&lt;/strong&gt; — better-sqlite3 persisted to disk, the most common SQLite setup&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;json-file&lt;/strong&gt; — full JSON file read/write on every operation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;lowdb&lt;/strong&gt; — JSON-based, keeps everything in memory and flushes to disk on write&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;lokijs&lt;/strong&gt; — in-memory document store, optional persistence&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All tests run on Apple M-series hardware. Results are in &lt;strong&gt;ops/sec&lt;/strong&gt; — so higher is better.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-results&quot;&gt;The results&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Operation                  pocket-db  sqlite (mem)  sqlite (file)  json-file      lowdb     lokijs
─────────────────────────────────────────────────────────────────────────────────────────────────
insertOne                    198,177     226,278 *         4,421      3,893      2,256      1,004
insertMany (100)               2,345       2,963 *           402        788        628        264
findById                     142,776   1,064,774         248,942  12,532,585 * 321,548  4,061,606
findAll                           96         361             361    115,774    870,822 *     1,179
findByName (scan)                 97         536             524     19,579     24,235 *     8,222
findByRole (index)               277         884             887     18,527     21,796 *     3,215
updateOne                     97,889     423,072 *         2,675        784        566        188
deleteOne                    454,402     465,026 *         5,551        924        663        220
countAll                      12,038   2,233,389         285,285  42,553,191 * 34,914,251 37,348,273
sortByScore (desc)                91         293             293      4,947      5,099 *     1,123
─────────────────────────────────────────────────────────────────────────────────────────────────
All values in ops/sec. * = fastest for this operation.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before you ask: json-file wins read benchmarks because the entire dataset is already loaded in memory during the benchmark.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-pocket-db-writes-so-fast&quot;&gt;Why pocket-db writes so fast&lt;/h3&gt;
&lt;p&gt;The short answer: every write is a single sequential append to the end of the file. No B-tree rebalancing. No page allocation. No full-file reserialisation.&lt;/p&gt;
&lt;p&gt;When you call &lt;code&gt;insertOne&lt;/code&gt;, &lt;code&gt;updateOne&lt;/code&gt; or &lt;code&gt;deleteOne&lt;/code&gt;, pocket-db appends one record to the log and updates the in-memory index pointer. That's it. An update doesn't touch the old record — it writes a replacement which is enough to marks the previous offset as dead. A delete appends a tombstone. This is why &lt;code&gt;deleteOne&lt;/code&gt; reaches 454,000 ops/sec, only marginally behind SQLite in-memory.&lt;/p&gt;
&lt;p&gt;There's a second reason the numbers look good today: &lt;strong&gt;no fsync wait&lt;/strong&gt;. Writes land in the OS page cache and return immediately. This is fast, but it's a trade-off — a hard crash before the OS flushes to disk can lose the last few writes. Pocket-db is perfectly capable to recover from incomplete operations, so consistency is never compromised. For the target use cases (CLI tools, desktop apps, local prototypes), this is an acceptable trade-off. For anything requiring strict durability guarantees, it's something to be aware of.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-reads-are-slower-and-what-s-being-done-about-it&quot;&gt;Why reads are slower — and what's being done about it&lt;/h3&gt;
&lt;p&gt;This is the honest part of the benchmark.&lt;/p&gt;
&lt;p&gt;pocket-db keeps only indexes in memory, not documents. Every &lt;code&gt;findOne&lt;/code&gt; and every cursor step seeks to the document's byte offset in the file and reads from disk. Every result then goes through JSON parsing. This is the fundamental cost of the current design — and it shows clearly in the numbers.&lt;/p&gt;
&lt;p&gt;In-memory stores like lowdb and LokiJS serve reads directly from JavaScript objects: no I/O, no parsing. That's why &lt;code&gt;findById&lt;/code&gt; on a json-file store reaches 12 million ops/sec while pocket-db sits at 142,000.&lt;/p&gt;
&lt;p&gt;Keeping indexes in memory was a necessary first compromise. Without them, reads would require a full file scan for every query — completely unusable in practice. Indexes bring &lt;code&gt;findById&lt;/code&gt; to a competitive level for real-world access patterns where you're not reading the same hot document in a tight loop. But the gap on read-heavy workloads is real, and it needs to be closed.&lt;/p&gt;
&lt;p&gt;The roadmap for the next version is to address this directly. The first candidate is &lt;strong&gt;a document cache&lt;/strong&gt;: keep recently accessed documents in memory and avoid redundant disk reads for hot-document workloads. This alone should close most of the gap for typical usage patterns. The second candidate is a &lt;strong&gt;binary document format&lt;/strong&gt; to replace JSON serialisation — parsing structured binary is significantly faster than &lt;code&gt;JSON.parse&lt;/code&gt; for complex documents. A third option under consideration is &lt;strong&gt;memory-mapped I/O with pagination&lt;/strong&gt;, which would allow the OS to manage the hot-document cache at a lower level.&lt;/p&gt;
&lt;p&gt;The memory footprint is also something to watch. Indexes in memory work well at the scales pocket-db is designed for — thousands to low hundreds of thousands of documents. As collections grow, that in-memory index cost grows with them. Load testing at larger scales is to be considered before locked of the architecture.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;reading-the-numbers-in-context&quot;&gt;Reading the numbers in context&lt;/h3&gt;
&lt;p&gt;The benchmark is deliberately narrow: 1,000 documents, ten operations, one process. That's a fair representation of the workloads pocket-db is built for — a CLI tool tracking state, an Electron app storing user data, a local server caching API responses.&lt;/p&gt;
&lt;p&gt;At that scale, the practical difference between 97 and 870,000 read ops/sec is manageable. A CLI command doesn't loop through &lt;code&gt;findByName&lt;/code&gt; a million times. But it does insert records frequently, update state on every run, and occasionally compact. For that profile, pocket-db's write throughput is genuinely competitive.&lt;/p&gt;
&lt;p&gt;Where the numbers are a real concern is if you're considering pocket-db for a workload that's read-heavy by nature — serving requests in a tight loop, scanning large collections on every query. For those cases today, a pure in-memory store is the honest recommendation.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;where-things-stand&quot;&gt;Where things stand&lt;/h3&gt;
&lt;p&gt;pocket-db is a young project. These benchmark results are encouraging for what it's designed to do: fast, durable writes in a single-file embedded store with a familiar API. The write story is solid. The read story needs work, and the work ahead is well defined.&lt;/p&gt;
&lt;p&gt;The next performance chapter — document cache, binary format, larger-scale index testing — will determine whether pocket-db can compete across the full spectrum of embedded workloads. That's the honest assessment at this first version.&lt;/p&gt;
&lt;p&gt;If you want to run the benchmarks yourself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/axfab/pocket-db
npm install
npm run bench
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And if a single-file, zero-dependency document store with a MongoDB-style API sounds like what your next project needs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @axfab/pocket-db
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;@axfab/pocket-db is MIT licensed. Contributions, issue reports, and benchmark challenges welcome.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24328/pocket-db-vs-lowdb-vs-lokijs-an-honest-embedded-database-benchmark</guid>
<pubDate>Sun, 09 Aug 2026 10:37:29 +0000</pubDate>
</item>
<item>
<title>Converting a JavaScript-Rendered Web Page to PDF</title>
<link>https://coderlegion.com/23939/converting-a-javascript-rendered-web-page-to-pdf</link>
<description>&lt;p&gt;If you've ever tried to turn a modern web page into a PDF programmatically, you've probably hit the wall: the file comes out blank, half-empty, or frozen on a loading spinner. The page looks perfect in the browser, so what gives?&lt;/p&gt;
&lt;p&gt;The answer is timing. Most PDF approaches grab the HTML &lt;em&gt;before&lt;/em&gt; the JavaScript has rendered the content. On a server-rendered page that's fine — the markup is already there. On a React/Vue/Angular app, the server sends an near-empty shell and the browser builds the DOM afterward. Capture too early and you save the shell.&lt;/p&gt;
&lt;p&gt;Here's how to do it properly.&lt;/p&gt;
&lt;h3 id=&quot;why-the-naive-approaches-fail&quot;&gt;Why the naive approaches fail&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;wkhtmltopdf&lt;/code&gt;&lt;/strong&gt; is the classic Google answer. It's fast and it's been around forever, but it uses an ancient WebKit build with effectively no modern JavaScript support. For a static page it's fine. For anything client-rendered, it captures the empty state.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Browser &lt;code&gt;window.print()&lt;/code&gt; / Ctrl+P&lt;/strong&gt; works because it &lt;em&gt;is&lt;/em&gt; a real browser — but it's manual, single-page, and impossible to automate cleanly at scale.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hitting the raw HTML with an HTTP client&lt;/strong&gt; (axios/fetch then pipe to a PDF lib) has the same fatal flaw as &lt;code&gt;wkhtmltopdf&lt;/code&gt;: no JS execution, no rendered content.&lt;/p&gt;
&lt;p&gt;What you actually need is a real browser engine that runs the page's JavaScript, waits for it to settle, and &lt;em&gt;then&lt;/em&gt; prints. That's Puppeteer.&lt;/p&gt;
&lt;h3 id=&quot;the-puppeteer-approach&quot;&gt;The Puppeteer approach&lt;/h3&gt;
&lt;p&gt;Puppeteer drives a headless Chromium. It executes the page exactly like a normal Chrome tab, so whatever renders on screen is what you capture.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const puppeteer = require('puppeteer');

async function pageToPdf(url, outPath) {
  const browser = await puppeteer.launch({
    args: ['--no-sandbox', '--disable-setuid-sandbox'], // needed in most containers
  });
  const page = await browser.newPage();

  await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });

  await page.pdf({
    path: outPath,
    format: 'A4',
    printBackground: true, // otherwise CSS backgrounds/colors are dropped
    margin: { top: '20px', bottom: '20px', left: '16px', right: '16px' },
  });

  await browser.close();
}

pageToPdf('https://example.com', 'out.pdf');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two options here matter more than they look:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;waitUntil: 'networkidle0'&lt;/code&gt;&lt;/strong&gt; tells Puppeteer to consider navigation finished only when there have been no network connections for 500ms. For most SPAs that's the signal the data has loaded and the DOM is built. (&lt;code&gt;networkidle2&lt;/code&gt; allows up to 2 connections — useful for pages with long-polling or analytics beacons that never fully go quiet.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;printBackground: true&lt;/code&gt;&lt;/strong&gt; — forget this and every background color, gradient, and image disappears, because Chrome's print path strips them by default.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-gotchas-that-still-bite-you&quot;&gt;The gotchas that still bite you&lt;/h3&gt;
&lt;p&gt;Getting a non-blank PDF is step one. Getting a &lt;em&gt;correct&lt;/em&gt; one is where the edges show up.&lt;/p&gt;
&lt;h4 id=&quot;lazy-loaded-content&quot;&gt;Lazy-loaded content&lt;/h4&gt;
&lt;p&gt;Images and sections that load on scroll won't be in the PDF, because Puppeteer never scrolled. Force them in by auto-scrolling before you print:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;await page.evaluate(async () =&amp;gt; {
  await new Promise((resolve) =&amp;gt; {
    let y = 0;
    const step = 300;
    const timer = setInterval(() =&amp;gt; {
      window.scrollBy(0, step);
      y += step;
      if (y &amp;gt;= document.body.scrollHeight) {
        clearInterval(timer);
        window.scrollTo(0, 0);
        resolve();
      }
    }, 100);
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;web-fonts-not-loaded-yet&quot;&gt;Web fonts not loaded yet&lt;/h4&gt;
&lt;p&gt;Text renders in a fallback font because the web font hadn't finished loading at print time. Wait for it explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;await page.evaluateHandle('document.fonts.ready');
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;print-css-overriding-your-layout&quot;&gt;Print CSS overriding your layout&lt;/h4&gt;
&lt;p&gt;If the site ships a &lt;code&gt;@media print&lt;/code&gt; stylesheet, Chrome applies it. Sometimes that's what you want; sometimes it nukes the layout. Force screen rendering with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;await page.emulateMediaType('screen');
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;fixed-headers-repeating-or-covering-content&quot;&gt;Fixed headers repeating or covering content&lt;/h4&gt;
&lt;p&gt;Sticky/fixed elements can duplicate on every PDF page or overlap the body. The reliable fix is to neutralize them before printing — set their position to &lt;code&gt;static&lt;/code&gt; (or hide them) via &lt;code&gt;page.addStyleTag()&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;when-self-hosting-puppeteer-isn-t-worth-it&quot;&gt;When self-hosting Puppeteer isn't worth it&lt;/h3&gt;
&lt;p&gt;Puppeteer is the right tool when you need PDFs inside a pipeline you control. But it comes with a real operational tail: a headless Chromium is heavy (200MB+), it needs the right system libraries in your container, it leaks memory if you don't manage browser instances carefully, and it falls over under concurrency unless you pool pages and cap parallelism. For a side feature — &quot;let users export this page&quot; — that's a lot of infrastructure to babysit.&lt;/p&gt;
&lt;p&gt;If you just need the output and not the plumbing, a hosted service does the same headless-render-then-capture under the hood without the ops burden. I run &lt;a rel=&quot;nofollow&quot; href=&quot;https://site2pdf.online&quot;&gt;site2pdf.online&lt;/a&gt; for exactly this — it renders each page in real Chrome (so JS content comes through), and it can also follow a site's internal links to capture multiple pages in one pass, exporting to PDF, PNG, or ZIP. Handy when the alternative is standing up and maintaining your own Chromium fleet.&lt;/p&gt;
&lt;p&gt;Rule of thumb: if PDF generation is core to your product, self-host Puppeteer and own it. If it's a convenience feature at the edge, offload it.&lt;/p&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;The blank-PDF problem is almost always a timing problem: capture happens before JavaScript renders. Use a real browser engine, wait for the network (and fonts) to settle, scroll to trigger lazy content, and enable &lt;code&gt;printBackground&lt;/code&gt;. Do that and dynamic pages convert cleanly.&lt;/p&gt;
&lt;p&gt;The full, copy-pasteable version of this — plus the fixed-header and multi-page handling — is worth keeping in a snippet somewhere. You'll reach for it more often than you'd expect.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23939/converting-a-javascript-rendered-web-page-to-pdf</guid>
<pubDate>Sat, 01 Aug 2026 18:11:47 +0000</pubDate>
</item>
<item>
<title>Blockend Logger vs Traditional Pino Logger</title>
<link>https://coderlegion.com/23850/blockend-logger-vs-traditional-pino-logger</link>
<description>&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/8kz5ikc5zhjw19a7ruzr.png&quot; alt=&quot;Comparison between blockend logo and traditional pino logger image&quot;&gt;&lt;br&gt;
Logging is essential, but setting it up with &lt;strong&gt;Pino&lt;/strong&gt; often means boilerplate: installing packages, wiring middleware, passing request IDs manually, and configuring redaction yourself. It works, but it’s repetitive.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Blockend Logger&lt;/strong&gt; takes a different approach. It uses Pino under the hood but generates a ready‑to‑use, type‑safe logger block directly into your project. You own the code, you can debug it, and you can change it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-blockend-logger-feels-different&quot;&gt;Why Blockend Logger Feels Different&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Shared logger instance across your app&lt;/li&gt;
&lt;li&gt;Automatic request ID propagation with &lt;code&gt;AsyncLocalStorage&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Framework adapters generated automatically (Express, Fastify, Hono)&lt;/li&gt;
&lt;li&gt;Request logging out of the box&lt;/li&gt;
&lt;li&gt;Built‑in redaction for sensitive fields&lt;/li&gt;
&lt;li&gt;Pretty logs in dev, JSON logs in prod&lt;/li&gt;
&lt;li&gt;TypeScript support baked in&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;honest-comparison&quot;&gt;Honest Comparison&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Feature &lt;/th&gt;&lt;th&gt;&lt;strong&gt;Blockend Logger&lt;/strong&gt; &lt;/th&gt;&lt;th&gt;&lt;strong&gt;Traditional Pino&lt;/strong&gt;&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Setup &lt;/td&gt;&lt;td&gt;&lt;code&gt;blockend-cli add logger&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Manual install + config&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Request IDs &lt;/td&gt;&lt;td&gt;Automatic &lt;/td&gt;&lt;td&gt;Manual middleware&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Framework integration &lt;/td&gt;&lt;td&gt;Auto‑generated adapter &lt;/td&gt;&lt;td&gt;Write your own&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Request logging &lt;/td&gt;&lt;td&gt;Built‑in &lt;/td&gt;&lt;td&gt;Add middleware manually&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Redaction &lt;/td&gt;&lt;td&gt;Preconfigured &lt;/td&gt;&lt;td&gt;Manual setup&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Output modes &lt;/td&gt;&lt;td&gt;Pretty dev / JSON prod &lt;/td&gt;&lt;td&gt;Configure transports yourself&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TypeScript &lt;/td&gt;&lt;td&gt;Generated &lt;code&gt;.ts&lt;/code&gt; files &lt;/td&gt;&lt;td&gt;Manual typings&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Blockend isn’t replacing Pino — it’s making Pino easier to use. If you want raw control, stick with Pino. If you want sane defaults and less boilerplate, Blockend Logger saves you time.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;learn-more&quot;&gt;Learn More&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://blockend.noorulhassan.com/docs/02-blocks/03-pino-logger&quot;&gt;Blockend Logger Documentation&lt;/a&gt;  &lt;/li&gt;
&lt;li&gt;Source: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/codewithnuh/blockend&quot;&gt;Blockend on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-word&quot;&gt;Final Word&lt;/h3&gt;
&lt;p&gt;Logging should help you debug, not slow you down. Blockend Logger is basically &lt;strong&gt;“Pino with batteries included.”&lt;/strong&gt; If you love it, &lt;strong&gt;star it on GitHub&lt;/strong&gt; — that’s the best way to support the project and help more developers discover it.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23850/blockend-logger-vs-traditional-pino-logger</guid>
<pubDate>Fri, 31 Jul 2026 10:37:52 +0000</pubDate>
</item>
<item>
<title>Kubernetes Cluster Autoscaler vs Karpenter: When to Use Each (2026)</title>
<link>https://coderlegion.com/23256/kubernetes-cluster-autoscaler-vs-karpenter-when-to-use-each-2026</link>
<description>
&lt;a href=&quot;https://alexandre-vazquez.com/cluster-autoscaler-vs-karpenter/&quot; title=&quot;Kubernetes Cluster Autoscaler vs Karpenter: When to Use Each (2026)&quot; rel=&quot;nofollow&quot;&gt;&lt;img width=&quot;1536&quot; height=&quot;1024&quot; src=&quot;https://mlk5tkvgfeoa.i.optimole.com/cb:s8WW.68/w:1536/h:1024/q:mauto/f:best/https://alexandre-vazquez.com/wp-content/uploads/2026/07/cluster-autoscaler-vs-karpenter-483d539ae1f4.avif&quot; class=&quot;webfeedsFeaturedVisual wp-post-image&quot; alt=&quot;Kubernetes Cluster Autoscaler vs Karpenter: When to Use Each (2026)&quot; style=&quot;display: block; margin: auto; margin-bottom: 5px;max-width: 100%;&quot; title=&quot;Kubernetes Cluster Autoscaler vs Karpenter: When to Use Each (2026) 16&quot;&gt;&lt;/a&gt;&lt;p&gt;Your pods are pending. Your on-call engineer is getting paged. Somewhere in the chain between &amp;ldquo;I need more compute&amp;rdquo; and &amp;ldquo;compute is available,&amp;rdquo; something is too slow. That something is almost always node provisioning &amp;mdash; and the tool you chose to manage it determines whether that delay is 4 minutes or 45 seconds.&lt;/p&gt;
&lt;p&gt;Node autoscaling is one of those infrastructure decisions that looks simple until you&amp;rsquo;re running it in production. Two schedulable pods sitting in Pending state doesn&amp;rsquo;t just mean a delayed deployment &amp;mdash; it means latency spikes, dropped traffic, breached SLOs, and engineers debugging things that should have been invisible. At scale, it also means either burning money on over-provisioned nodes or gambling on under-provisioning at the worst possible moment.&lt;/p&gt;
&lt;p&gt;Cluster Autoscaler (CA) has been the default answer for years. Karpenter emerged from AWS in 2021, graduated to stable in 2023, and by 2025 had become the default recommendation for most AWS-native clusters. In 2026, both tools are mature, widely deployed, and genuinely good &amp;mdash; but they solve the problem differently, and picking the wrong one for your environment has real consequences.&lt;/p&gt;
&lt;p&gt;This article is a deep technical comparison. It assumes you already know what Kubernetes is and have opinions about infrastructure. The goal is to give you a clear picture of &lt;em&gt;how&lt;/em&gt; each tool works, where each one wins, and a decision framework you can actually use.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-node-autoscaling-is-hard&quot;&gt;Why Node Autoscaling Is Hard&lt;/h2&gt;
&lt;p&gt;The fundamental tension in autoscaling is this: you want compute available before you need it, but you don&amp;rsquo;t want to pay for compute you&amp;rsquo;re not using. These goals are in direct conflict, and every autoscaling system is an attempt to find the least-bad trade-off.&lt;/p&gt;
&lt;p&gt;Without autoscaling, you&amp;rsquo;re doing one of two things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Over-provisioning&lt;/strong&gt; &amp;mdash; you run enough nodes to handle peak load at all times. Your average utilization sits at 20&amp;ndash;30%, and you&amp;rsquo;re paying for the other 70&amp;ndash;80% to sit idle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Under-provisioning&lt;/strong&gt; &amp;mdash; you run lean, and when traffic spikes, pods go Pending. Your SLOs breach. You get paged at 3am to manually scale.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A common failure mode with poorly tuned autoscaling is the &amp;ldquo;thundering herd at scale-up&amp;rdquo; pattern: HPA creates new pods faster than node autoscaling can provision capacity. The provisioning window matters. With CA and typical ASG-backed node groups on AWS, you&amp;rsquo;re looking at 4&amp;ndash;8 minutes. With Karpenter, 60&amp;ndash;90 seconds. At 100 RPS and a 3-minute window, that&amp;rsquo;s 18,000 requests under degraded conditions.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;cluster-autoscaler-how-it-actually-works&quot;&gt;Cluster Autoscaler: How It Actually Works&lt;/h2&gt;
&lt;p&gt;Cluster Autoscaler is a Kubernetes-native project under the kubernetes/autoscaler repository, in production since 2016, supporting AWS, GCP, Azure, Alibaba, DigitalOcean, and more.&lt;/p&gt;
&lt;h3 id=&quot;the-node-group-model&quot;&gt;The Node Group Model&lt;/h3&gt;
&lt;p&gt;CA operates on &lt;strong&gt;node groups&lt;/strong&gt; &amp;mdash; ASGs on AWS, MIGs on GCP, VMSSs on Azure. CA&amp;rsquo;s job is to decide when to increase or decrease the desired capacity of these groups. &lt;strong&gt;CA does not provision individual nodes. It scales node groups, and the node group provisions nodes.&lt;/strong&gt; This indirection adds latency and reduces flexibility.&lt;/p&gt;
&lt;h3 id=&quot;scale-up-detecting-unschedulable-pods&quot;&gt;Scale-Up: Detecting Unschedulable Pods&lt;/h3&gt;
&lt;p&gt;CA runs a control loop (default scan interval: 10 seconds). For each &lt;code&gt;Pending&lt;/code&gt; pod with &lt;code&gt;PodScheduled=False&lt;/code&gt;, CA simulates adding a node of each known node group type and checks if the pod would become schedulable. When a node group is selected, CA applies an &lt;strong&gt;expander&lt;/strong&gt; to choose which group to scale:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;least-waste&lt;/code&gt; &amp;mdash; minimizes CPU/memory waste after scheduling (best default for cost)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;most-pods&lt;/code&gt; &amp;mdash; maximizes pods scheduled per scale-up operation&lt;/li&gt;
&lt;li&gt;&lt;code&gt;priority&lt;/code&gt; &amp;mdash; lets you define ordering via ConfigMap&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grpc&lt;/code&gt; &amp;mdash; delegates to an external gRPC service&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;highlight&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Cluster Autoscaler deployment &amp;mdash; AWS, production-tuned
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cluster-autoscaler
  namespace: kube-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cluster-autoscaler
  template:
    metadata:
      labels:
        app: cluster-autoscaler
      annotations:
        cluster-autoscaler.kubernetes.io/safe-to-evict: &quot;false&quot;
    spec:
      priorityClassName: system-cluster-critical
      serviceAccountName: cluster-autoscaler
      containers:
        - image: registry.k8s.io/autoscaling/cluster-autoscaler:v1.30.3
          name: cluster-autoscaler
          resources:
            requests:
              cpu: 100m
              memory: 600Mi
            limits:
              cpu: 200m
              memory: 1Gi
          command:
            - ./cluster-autoscaler
            - --cloud-provider=aws
            - --node-group-auto-discovery=asg:tag=k8s.io/cluster-autoscaler/enabled,k8s.io/cluster-autoscaler/my-cluster
            - --expander=least-waste
            - --balance-similar-node-groups=true
            - --scale-down-delay-after-add=10m
            - --scale-down-unneeded-time=10m
            - --scale-down-utilization-threshold=0.5
            - --max-graceful-termination-sec=600
            - --scan-interval=10s
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;scale-down-the-conservative-approach&quot;&gt;Scale-Down: The Conservative Approach&lt;/h3&gt;
&lt;p&gt;A node is a scale-down candidate only if:&lt;br&gt;
&amp;ndash; CPU and memory utilization (by requests) is below threshold (default: 50%)&lt;br&gt;
&amp;ndash; All pods could be rescheduled elsewhere&lt;br&gt;
&amp;ndash; No pod has &lt;code&gt;cluster-autoscaler.kubernetes.io/safe-to-evict: &quot;false&quot;&lt;/code&gt;&lt;br&gt;
&amp;ndash; The node has been underutilized for at least &lt;code&gt;--scale-down-unneeded-time&lt;/code&gt; (default: 10m)&lt;/p&gt;
&lt;p&gt;This conservatism prevents churn &amp;mdash; a feature, not a bug.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;karpenter-how-it-actually-works&quot;&gt;Karpenter: How It Actually Works&lt;/h2&gt;
&lt;p&gt;Karpenter is a CNCF incubating project originally built by AWS, donated to CNCF in 2023, GA (v1.0) in mid-2024. Providers exist for AWS (stable), Azure (stable), and GCP (beta).&lt;/p&gt;
&lt;h3 id=&quot;the-core-insight-bypass-the-node-group&quot;&gt;The Core Insight: Bypass the Node Group&lt;/h3&gt;
&lt;p&gt;Karpenter &lt;strong&gt;calls the EC2 &lt;code&gt;RunInstances&lt;/code&gt; API directly&lt;/strong&gt; &amp;mdash; no ASG involvement. This means:&lt;br&gt;
&amp;ndash; Any instance type in a single request, without pre-configuring a node group&lt;br&gt;
&amp;ndash; No intermediary: Karpenter &amp;rarr; EC2 API &amp;rarr; node joins cluster&lt;br&gt;
&amp;ndash; Right-size nodes to exactly what workloads need, across the full instance catalog&lt;br&gt;
&amp;ndash; Karpenter handles full node lifecycle, including termination&lt;/p&gt;
&lt;h3 id=&quot;nodepool-and-ec2nodeclass&quot;&gt;NodePool and EC2NodeClass&lt;/h3&gt;
&lt;pre class=&quot;highlight&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;# EC2NodeClass &amp;mdash; cloud-specific parameters
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  amiSelectorTerms:
    - alias: al2023@latest
  role: &quot;KarpenterNodeRole-my-cluster&quot;
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: &quot;my-cluster&quot;
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: &quot;my-cluster&quot;
  blockDeviceMappings:
    - deviceName: /dev/xvda
      ebs:
        volumeSize: 50Gi
        volumeType: gp3
        encrypted: true
  metadataOptions:
    httpTokens: required
    httpPutResponseHopLimit: 1
---
# NodePool &amp;mdash; intent and constraints
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: general-purpose
spec:
  template:
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: [&quot;on-demand&quot;, &quot;spot&quot;]
        - key: kubernetes.io/arch
          operator: In
          values: [&quot;amd64&quot;, &quot;arm64&quot;]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: [&quot;c&quot;, &quot;m&quot;, &quot;r&quot;]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: [&quot;5&quot;]
        - key: karpenter.k8s.aws/instance-size
          operator: NotIn
          values: [&quot;nano&quot;, &quot;micro&quot;, &quot;small&quot;, &quot;medium&quot;, &quot;large&quot;]
      expireAfter: 720h
  limits:
    cpu: &quot;1000&quot;
    memory: 1000Gi
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 5m
    budgets:
      - nodes: &quot;5%&quot;
        schedule: &quot;0 8 * * mon-fri&quot;
        duration: 10h
      - nodes: &quot;25%&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;just-in-time-provisioning-and-bin-packing&quot;&gt;Just-in-Time Provisioning and Bin Packing&lt;/h3&gt;
&lt;p&gt;When pods go Pending, Karpenter watches the event (not polls) and immediately:&lt;br&gt;
1. Collects all Pending pods&lt;br&gt;
2. Simulates bin packing &amp;mdash; fewest possible nodes across the full instance catalog&lt;br&gt;
3. Selects instances that satisfy all pod requirements&lt;br&gt;
4. Calls EC2 API to provision the optimal instance(s)&lt;/p&gt;
&lt;h3 id=&quot;disruption-and-consolidation&quot;&gt;Disruption and Consolidation&lt;/h3&gt;
&lt;p&gt;Karpenter&amp;rsquo;s differentiated value: active cluster consolidation. It evaluates whether nodes can be removed by redistributing pods onto others, or &lt;strong&gt;replaced with a smaller instance type&lt;/strong&gt;. A &lt;code&gt;c5.4xlarge&lt;/code&gt; running 4 vCPU worth of pods gets replaced with a &lt;code&gt;c5.xlarge&lt;/code&gt;. Teams commonly report 30&amp;ndash;50% compute cost reduction.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;consolidationPolicy&lt;/code&gt; options:&lt;br&gt;
&amp;ndash; &lt;code&gt;WhenEmpty&lt;/code&gt; &amp;mdash; only remove nodes with no workload pods (safest)&lt;br&gt;
&amp;ndash; &lt;code&gt;WhenEmptyOrUnderutilized&lt;/code&gt; &amp;mdash; also replace underutilized nodes with smaller ones&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;architecture-comparison&quot;&gt;Architecture Comparison&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Cluster Autoscaler&lt;/th&gt;
&lt;th&gt;Karpenter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Node provisioning model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scales node groups (ASG/MIG/VMSS)&lt;/td&gt;
&lt;td&gt;Direct cloud API, no node groups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Instance flexibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pre-defined node group types&lt;/td&gt;
&lt;td&gt;Full instance catalog at runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scale-up trigger&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Polling (10s scan interval)&lt;/td&gt;
&lt;td&gt;Watch-based event (near-instant)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scale-down&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Removes underutilized nodes&lt;/td&gt;
&lt;td&gt;Removes + consolidates + right-sizes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Spot handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Via ASG + AWS Node Termination Handler&lt;/td&gt;
&lt;td&gt;Native, first-class, no NTH needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Configuration model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Deployment flags&lt;/td&gt;
&lt;td&gt;Declarative CRDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloud support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;All major + on-prem&lt;/td&gt;
&lt;td&gt;AWS (GA), Azure (stable), GCP (beta)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Consolidation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Community maturity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Very mature (since 2016)&lt;/td&gt;
&lt;td&gt;Mature (GA 2024)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&quot;scaling-speed-the-numbers&quot;&gt;Scaling Speed: The Numbers&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Cluster Autoscaler on AWS (typical):&lt;/strong&gt;&lt;br&gt;
1. Pod Pending &amp;rarr; CA scan detects (0&amp;ndash;10s)&lt;br&gt;
2. ASG UpdateAutoScalingGroup API call (~15&amp;ndash;30s)&lt;br&gt;
3. EC2 instance starts (1&amp;ndash;2 min)&lt;br&gt;
4. Node bootstrap + kubelet registration (30&amp;ndash;60s)&lt;br&gt;
5. Pod scheduled (5&amp;ndash;10s)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Total: 3&amp;ndash;6 minutes&lt;/strong&gt; (up to 12 min during high-demand periods)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Karpenter on AWS (typical):&lt;/strong&gt;&lt;br&gt;
1. Pod Pending &amp;rarr; watch event fires (~1s)&lt;br&gt;
2. EC2 RunInstances API call (~2&amp;ndash;3s)&lt;br&gt;
3. EC2 instance starts (same hardware &amp;mdash; 1&amp;ndash;2 min)&lt;br&gt;
4. Node bootstraps + Ready (30&amp;ndash;60s)&lt;br&gt;
5. Pod scheduled (~5s)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Total: 90 seconds to 3 minutes&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;cost-optimization-where-karpenter-pulls-ahead&quot;&gt;Cost Optimization: Where Karpenter Pulls Ahead&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Right-sizing:&lt;/strong&gt; CA requires pre-defined node groups. Karpenter selects the minimum viable instance for the pending workload from the full catalog.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Consolidation vs scale-down:&lt;/strong&gt; CA removes underutilized nodes. Karpenter replaces a large underutilized node with a smaller one that still fits all pods. This produces compounding savings over time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Spot handling:&lt;/strong&gt; Karpenter receives EC2 interruption notices, pre-provisions a replacement, and drains the node &amp;mdash; all within the 2-minute window. No AWS Node Termination Handler required. It also diversifies spot requests across instance types automatically to reduce simultaneous interruption risk.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;multi-cloud-support-in-2026&quot;&gt;Multi-Cloud Support in 2026&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cloud&lt;/th&gt;
&lt;th&gt;Cluster Autoscaler&lt;/th&gt;
&lt;th&gt;Karpenter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://s.w.org/images/core/emoji/16.0.1/72x72/2705.png&quot; alt=&quot;&amp;#9989;&quot; class=&quot;wp-smiley&quot; style=&quot;height: 1em; max-height: 1em;&quot;&gt; Production-stable&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://s.w.org/images/core/emoji/16.0.1/72x72/2705.png&quot; alt=&quot;&amp;#9989;&quot; class=&quot;wp-smiley&quot; style=&quot;height: 1em; max-height: 1em;&quot;&gt; Production-stable (reference impl.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GCP&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://s.w.org/images/core/emoji/16.0.1/72x72/2705.png&quot; alt=&quot;&amp;#9989;&quot; class=&quot;wp-smiley&quot; style=&quot;height: 1em; max-height: 1em;&quot;&gt; Production-stable&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://s.w.org/images/core/emoji/16.0.1/72x72/26a0.png&quot; alt=&quot;&amp;#9888;&quot; class=&quot;wp-smiley&quot; style=&quot;height: 1em; max-height: 1em;&quot;&gt; Beta (karpenter-provider-gcp)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://s.w.org/images/core/emoji/16.0.1/72x72/2705.png&quot; alt=&quot;&amp;#9989;&quot; class=&quot;wp-smiley&quot; style=&quot;height: 1em; max-height: 1em;&quot;&gt; Production-stable&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://s.w.org/images/core/emoji/16.0.1/72x72/2705.png&quot; alt=&quot;&amp;#9989;&quot; class=&quot;wp-smiley&quot; style=&quot;height: 1em; max-height: 1em;&quot;&gt; Stable (karpenter-provider-azure)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alibaba&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://s.w.org/images/core/emoji/16.0.1/72x72/2705.png&quot; alt=&quot;&amp;#9989;&quot; class=&quot;wp-smiley&quot; style=&quot;height: 1em; max-height: 1em;&quot;&gt; Supported&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://s.w.org/images/core/emoji/16.0.1/72x72/274c.png&quot; alt=&quot;&amp;#10060;&quot; class=&quot;wp-smiley&quot; style=&quot;height: 1em; max-height: 1em;&quot;&gt; No provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DigitalOcean&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://s.w.org/images/core/emoji/16.0.1/72x72/2705.png&quot; alt=&quot;&amp;#9989;&quot; class=&quot;wp-smiley&quot; style=&quot;height: 1em; max-height: 1em;&quot;&gt; Supported&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://s.w.org/images/core/emoji/16.0.1/72x72/274c.png&quot; alt=&quot;&amp;#10060;&quot; class=&quot;wp-smiley&quot; style=&quot;height: 1em; max-height: 1em;&quot;&gt; No provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On-premises / Cluster API&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://s.w.org/images/core/emoji/16.0.1/72x72/2705.png&quot; alt=&quot;&amp;#9989;&quot; class=&quot;wp-smiley&quot; style=&quot;height: 1em; max-height: 1em;&quot;&gt; Supported&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://s.w.org/images/core/emoji/16.0.1/72x72/274c.png&quot; alt=&quot;&amp;#10060;&quot; class=&quot;wp-smiley&quot; style=&quot;height: 1em; max-height: 1em;&quot;&gt; Not supported&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&quot;when-cluster-autoscaler-is-still-the-right-choice&quot;&gt;When Cluster Autoscaler Is Still the Right Choice&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Non-AWS environments&lt;/strong&gt; &amp;mdash; GCP, Alibaba, DigitalOcean, on-prem with Cluster API&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Existing node group architecture&lt;/strong&gt; &amp;mdash; significant investment in ASG design, compliance tooling&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Regulatory constraints&lt;/strong&gt; &amp;mdash; some frameworks require ASG-backed provisioning audit trails&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cluster API / bare metal&lt;/strong&gt; &amp;mdash; CA is the only mature option&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Team familiarity and working-well CA deployment&lt;/strong&gt; &amp;mdash; migration cost may not justify benefit&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 id=&quot;when-karpenter-is-the-right-choice&quot;&gt;When Karpenter Is the Right Choice&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;AWS-native, cost optimization priority&lt;/strong&gt; &amp;mdash; right-sizing + consolidation = meaningful cost reduction&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Diverse and variable workloads&lt;/strong&gt; &amp;mdash; batch, spot, GPU, stateless APIs &amp;mdash; Karpenter handles all with a few NodePools&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spot-heavy clusters&lt;/strong&gt; &amp;mdash; native interruption handling, diversification, no NTH&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Declarative infrastructure-as-code culture&lt;/strong&gt; &amp;mdash; NodePools version cleanly in Git&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Low-latency scaling requirements&lt;/strong&gt; &amp;mdash; event-driven workloads, KEDA-triggered jobs, sharp traffic spikes&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 id=&quot;running-both-migration-path-and-gotchas&quot;&gt;Running Both: Migration Path and Gotchas&lt;/h2&gt;
&lt;h3 id=&quot;separating-responsibility&quot;&gt;Separating Responsibility&lt;/h3&gt;
&lt;p&gt;Use labels and taints to prevent CA and Karpenter from managing the same nodes:&lt;/p&gt;
&lt;pre class=&quot;highlight&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;# NodePool with taint &amp;mdash; CA-managed pods won't tolerate this
spec:
  template:
    metadata:
      labels:
        provisioner: karpenter
    spec:
      taints:
        - key: karpenter.sh/provisioned
          value: &quot;true&quot;
          effect: NoSchedule
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;gradual-migration&quot;&gt;Gradual Migration&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Phase 1&lt;/strong&gt; &amp;mdash; Karpenter manages spot/batch workloads. CA manages on-demand production nodes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 2&lt;/strong&gt; &amp;mdash; Migrate spot workloads fully. Remove AWS NTH.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 3&lt;/strong&gt; &amp;mdash; Migrate on-demand. Reduce CA node group capacity gradually.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 4&lt;/strong&gt; &amp;mdash; Decommission CA once all groups are empty.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;key-gotchas&quot;&gt;Key Gotchas&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Karpenter consolidation + permissive PDBs&lt;/strong&gt; &amp;mdash; &lt;code&gt;maxUnavailable: 100%&lt;/code&gt; will cause disruptive consolidation. Audit PDBs before enabling &lt;code&gt;WhenEmptyOrUnderutilized&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NodePool limits are hard stops&lt;/strong&gt; &amp;mdash; pods go Pending indefinitely at limit. Monitor utilization.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AMI drift&lt;/strong&gt; &amp;mdash; &lt;code&gt;@latest&lt;/code&gt; alias picks up new AMIs on new nodes. Consider pinning for strict change control.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Simultaneous scale-down conflicts&lt;/strong&gt; &amp;mdash; use strict label/taint segregation during migration.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;decision-framework&quot;&gt;Decision Framework&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Cluster Autoscaler&lt;/th&gt;
&lt;th&gt;Karpenter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloud support&lt;/td&gt;
&lt;td&gt;All clouds +&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/23256/kubernetes-cluster-autoscaler-vs-karpenter-when-to-use-each-2026</guid>
<pubDate>Thu, 30 Jul 2026 10:00:00 +0000</pubDate>
</item>
<item>
<title>EKS Auto Mode: What It Actually Changes (and What It Doesn’t)</title>
<link>https://coderlegion.com/23253/eks-auto-mode-what-it-actually-changes-and-what-it-doesnt</link>
<description>
&lt;a href=&quot;https://alexandre-vazquez.com/eks-auto-mode/&quot; title=&quot;EKS Auto Mode: What It Actually Changes (and What It Doesn&amp;rsquo;t)&quot; rel=&quot;nofollow&quot;&gt;&lt;img width=&quot;1536&quot; height=&quot;1024&quot; src=&quot;https://mlk5tkvgfeoa.i.optimole.com/cb:s8WW.68/w:1536/h:1024/q:mauto/f:best/https://alexandre-vazquez.com/wp-content/uploads/2026/07/eks.avif&quot; class=&quot;webfeedsFeaturedVisual wp-post-image&quot; alt=&quot;EKS Auto Mode: What It Actually Changes (and What It Doesn't)&quot; style=&quot;display: block; margin: auto; margin-bottom: 5px;max-width: 100%;&quot; title=&quot;EKS Auto Mode: What It Actually Changes (and What It Doesn't) 10&quot;&gt;&lt;/a&gt;
&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;What EKS Auto Mode is&lt;/h2&gt;



&lt;p&gt;EKS Auto Mode, generally available on &lt;a href=&quot;https://aws.amazon.com/blogs/aws/streamline-kubernetes-cluster-management-with-new-amazon-eks-auto-mode/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;December 1, 2024&lt;/a&gt;, shifts more Kubernetes infrastructure responsibility from you to AWS. You still run an EKS cluster in your AWS account, and your workloads still use the Kubernetes API, but AWS takes over much of the compute, storage, networking, node lifecycle, and core add-on management that platform teams usually wire together themselves.&lt;/p&gt;



&lt;p&gt;For compute, Auto Mode uses &lt;a rel=&quot;nofollow&quot; href=&quot;https://alexandre-vazquez.com/cluster-autoscaler-vs-karpenter/&quot;&gt;Karpenter-style provisioning&lt;/a&gt; under the hood. When pods are unschedulable, Auto Mode provisions nodes that fit the workload&amp;rsquo;s requirements: instance family, size, architecture, capacity type, and availability zone. When capacity is no longer useful, it can consolidate and terminate nodes.&lt;/p&gt;



&lt;p&gt;The important framing is this: Auto Mode is not &amp;ldquo;EKS without nodes.&amp;rdquo; It is EKS where AWS manages the node lifecycle more aggressively. You own the workloads, their scheduling requirements, their disruption behavior, and the operational consequences of those choices. AWS owns more of the infrastructure plumbing.&lt;/p&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;What it replaces&lt;/h2&gt;



&lt;p&gt;Before Auto Mode, running EKS in production usually meant choosing and operating several layers yourself:&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Managed node groups:&lt;/strong&gt; You chose instance types, defined scaling ranges, managed AMI updates, handled node draining, and configured Cluster Autoscaler or another scaling mechanism.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Self-managed Karpenter:&lt;/strong&gt; More flexible than managed node groups, but you owned the Karpenter controller, IAM, NodePools, EC2NodeClasses, disruption settings, upgrades, and failure modes.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Fargate:&lt;/strong&gt; AWS-managed compute per pod, with no node management, but no DaemonSets, a narrower workload compatibility envelope, and a different cost model.&lt;/p&gt;



&lt;p&gt;EKS Auto Mode replaces a large part of that platform assembly with a managed model: declare workload intent and high-level compute constraints; AWS provisions and manages the EC2 instances behind it.&lt;/p&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;How it works in practice&lt;/h2&gt;



&lt;p&gt;You create or update an EKS cluster with Auto Mode enabled. The default setup can use AWS-managed built-in node pools. If you need more control, you create a &lt;code&gt;NodeClass&lt;/code&gt; for Auto Mode infrastructure settings and a Karpenter &lt;code&gt;NodePool&lt;/code&gt; for workload-facing scheduling constraints.&lt;/p&gt;



&lt;pre class=&quot;wp-block-code&quot;&gt;&lt;code&gt;# NodeClass: EKS Auto Mode infrastructure settings for managed EC2 nodes.
apiVersion: eks.amazonaws.com/v1
kind: NodeClass
metadata:
  name: private-compute
spec:
  subnetSelectorTerms:
    - tags:
        kubernetes.io/role/internal-elb: &quot;1&quot;
  securityGroupSelectorTerms:
    - tags:
        aws:eks:cluster-name: prod-eks
  ephemeralStorage:
    size: &quot;100Gi&quot;&lt;/code&gt;&lt;/pre&gt;



&lt;pre class=&quot;wp-block-code&quot;&gt;&lt;code&gt;# NodePool: workload-facing constraints for nodes that Auto Mode may provision.
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: general-purpose
spec:
  template:
    spec:
      nodeClassRef:
        group: eks.amazonaws.com
        kind: NodeClass
        name: private-compute
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: [&quot;on-demand&quot;, &quot;spot&quot;]
        - key: kubernetes.io/arch
          operator: In
          values: [&quot;amd64&quot;, &quot;arm64&quot;]
        - key: eks.amazonaws.com/instance-category
          operator: In
          values: [&quot;c&quot;, &quot;m&quot;, &quot;r&quot;]
  limits:
    cpu: &quot;1000&quot;
    memory: 1000Gi
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 1m&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Those API groups are the Auto Mode-specific split documented by AWS: &lt;code&gt;apiVersion: eks.amazonaws.com/v1&lt;/code&gt;, &lt;code&gt;kind: NodeClass&lt;/code&gt; for Auto Mode node infrastructure, and &lt;code&gt;apiVersion: karpenter.sh/v1&lt;/code&gt;, &lt;code&gt;kind: NodePool&lt;/code&gt; for scheduling and capacity constraints.&lt;/p&gt;



&lt;p&gt;The &lt;code&gt;NodeClass&lt;/code&gt; is where you express AWS infrastructure placement and node-level defaults. The &lt;code&gt;NodePool&lt;/code&gt; is where you express what kind of capacity is acceptable for workloads. Do not copy a self-managed Karpenter &lt;code&gt;EC2NodeClass&lt;/code&gt; into Auto Mode; Auto Mode uses its own &lt;code&gt;NodeClass&lt;/code&gt; API.&lt;/p&gt;



&lt;p&gt;Auto Mode provisions nodes when pods are pending, consolidates when nodes are underutilized, and replaces nodes during maintenance or scale-down. AMI and node lifecycle updates are handled by AWS. AWS says Auto Mode AMIs are generally released weekly with CVE and security fixes, and Auto Mode nodes have a maximum lifetime of 21 days, which you can reduce. Your application still has to tolerate the disruption: a bad &lt;code&gt;PodDisruptionBudget&lt;/code&gt;, strict affinity rule, or singleton stateful workload can still block or degrade a replacement.&lt;/p&gt;



&lt;p&gt;Built-in components are managed differently than in a classic EKS build. AWS lists pod networking, service networking, cluster DNS, autoscaling, block storage, load balancer controller, Pod Identity agent, and node monitoring agent as Auto Mode capabilities. With Auto Mode compute, common add-ons such as Amazon VPC CNI, kube-proxy, CoreDNS, Amazon EBS CSI Driver, and EKS Pod Identity Agent become redundant for Auto Mode nodes, and the relevant controllers can run on AWS-owned infrastructure rather than as visible pods in your account. You can still install AWS Load Balancer Controller in an Auto Mode cluster when you need both models during migration, but AWS does not support directly migrating existing load balancers from AWS Load Balancer Controller to Auto Mode; use &lt;code&gt;IngressClass&lt;/code&gt; or &lt;code&gt;loadBalancerClass&lt;/code&gt; boundaries and plan blue-green migration. Treat this as a change in ownership, not as a reason to skip validation.&lt;/p&gt;



&lt;p&gt;The workload support matrix is broader than Fargate, but not identical to self-managed nodes:&lt;/p&gt;



&lt;figure class=&quot;wp-block-table&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Capability&lt;/th&gt;&lt;th&gt;Auto Mode status&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;EC2 Spot&lt;/td&gt;&lt;td&gt;Supported through &lt;code&gt;karpenter.sh/capacity-type&lt;/code&gt; requirements such as &lt;code&gt;spot&lt;/code&gt;, &lt;code&gt;on-demand&lt;/code&gt;, and &lt;code&gt;reserved&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Graviton / arm64&lt;/td&gt;&lt;td&gt;Supported through &lt;code&gt;kubernetes.io/arch: arm64&lt;/code&gt; and supported Graviton instance families&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;GPU / accelerators&lt;/td&gt;&lt;td&gt;Supported for documented accelerated families; Auto Mode manages NVIDIA, Trainium, and Inferentia drivers/device plugins for supported instance types&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Windows nodes&lt;/td&gt;&lt;td&gt;Not supported&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;DaemonSets&lt;/td&gt;&lt;td&gt;Supported as Kubernetes DaemonSets, but host-level assumptions must be validated against locked-down managed nodes&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/figure&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;What you gain&lt;/h2&gt;



&lt;p&gt;&lt;strong&gt;Reduced operational surface.&lt;/strong&gt; Node group management, AMI lifecycle, Cluster Autoscaler tuning, Karpenter controller upgrades, and a chunk of add-on wiring move out of your day-to-day scope.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Better provisioning shape by default.&lt;/strong&gt; Dynamic provisioning is usually a better fit than fixed node group shapes. You get nodes that more closely match actual pod requirements instead of trying to pre-plan a small set of instance types.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Automatic node patching.&lt;/strong&gt; AWS manages the node image and replacement flow. That reduces toil, but it also means your workloads need disruption policies that let AWS replace nodes safely.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Faster cluster bootstrapping.&lt;/strong&gt; A new Auto Mode cluster can get to a usable production baseline faster than a hand-assembled EKS cluster with node groups, autoscaling, networking add-ons, storage drivers, and load balancer controllers.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Native Spot integration.&lt;/strong&gt; Auto Mode can use Spot capacity through NodePool requirements, but you still need workload-level interruption tolerance: replicas, budgets, graceful shutdown, and queue semantics where relevant.&lt;/p&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;What you give up&lt;/h2&gt;



&lt;p&gt;&lt;strong&gt;Node-level access.&lt;/strong&gt; Auto Mode nodes are intentionally locked down compared with traditional self-managed nodes. If your incident response process assumes SSH, SSM, manual package inspection, or ad hoc host changes, it needs to change.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Custom AMIs.&lt;/strong&gt; You cannot treat the node image as your own artifact. AWS determines the operating system and AMI for Auto Mode managed instances; you cannot directly access the instance or install software on it. If your organization requires internally built, hardened, or certified AMIs, Auto Mode is likely blocked.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Unrestricted host agents.&lt;/strong&gt; Kubernetes DaemonSets are supported, but they are the sharp edge. Some node agents work; others do not. Anything that assumes privileged host access, custom kernel modules, hostPath writes, IMDS access without &lt;code&gt;hostNetwork&lt;/code&gt;, or low-level runtime integration needs a proof of compatibility.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Less tuning surface.&lt;/strong&gt; You give up direct control over kubelet flags, container runtime configuration, bootstrap scripts, and arbitrary node setup. That is the point of the product, but it is also the boundary.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Different cost visibility.&lt;/strong&gt; Managed node groups make capacity easier to reason about because you chose it up front. Auto Mode changes capacity dynamically, so cost control moves toward budgets, labels, reports, and &lt;a rel=&quot;nofollow&quot; href=&quot;https://alexandre-vazquez.com/2026-05-kubernetes-resource-requests-limits/&quot;&gt;workload-level resource hygiene&lt;/a&gt;.&lt;/p&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;EKS Auto Mode vs managed node groups vs Fargate&lt;/h2&gt;



&lt;figure class=&quot;wp-block-table&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Auto Mode&lt;/th&gt;&lt;th&gt;Managed Node Groups&lt;/th&gt;&lt;th&gt;Fargate&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Node management&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;AWS manages node lifecycle&lt;/td&gt;&lt;td&gt;Shared: AWS manages the group primitive, you manage capacity shape and many updates&lt;/td&gt;&lt;td&gt;AWS-managed per-pod compute&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;AMI updates&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Automatic through AWS-managed node replacement&lt;/td&gt;&lt;td&gt;You schedule and operate rolling updates&lt;/td&gt;&lt;td&gt;N/A&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Instance selection&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Dynamic through NodePools&lt;/td&gt;&lt;td&gt;You choose instance types and scaling ranges&lt;/td&gt;&lt;td&gt;Not exposed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Custom AMIs&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;No; AWS determines the AMI&lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;DaemonSets&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Supported, but validate host-access assumptions&lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;SSH / node access&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Restricted&lt;/td&gt;&lt;td&gt;Usually available if you enable it&lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Spot support&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Yes, through capacity-type requirements&lt;/td&gt;&lt;td&gt;Yes, with node group or Karpenter design&lt;/td&gt;&lt;td&gt;No; Amazon EKS does not support Fargate Spot&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Cost model&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;EKS control plane + EC2 + EKS Auto Mode fee&lt;/td&gt;&lt;td&gt;EKS control plane + EC2&lt;/td&gt;&lt;td&gt;EKS control plane + Fargate pod pricing&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Operational burden&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Low for nodes, medium for workload compatibility&lt;/td&gt;&lt;td&gt;Medium&lt;/td&gt;&lt;td&gt;Low for nodes, medium for compatibility&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Right for&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Default candidate for teams that do not need node customization&lt;/td&gt;&lt;td&gt;Regulated/custom node environments and mature platform teams&lt;/td&gt;&lt;td&gt;Workloads that fit Fargate&amp;rsquo;s restrictions and want per-pod isolation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Hidden constraints / gotchas&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;AWS controls node image and lifecycle; DaemonSets, privileged pods, hostPath, PDBs, topology rules, and node agents can block migration&lt;/td&gt;&lt;td&gt;You still own AMI drift, autoscaler tuning, disruption handling, and capacity fragmentation&lt;/td&gt;&lt;td&gt;No DaemonSets, limited host-level integrations, different networking/storage constraints, and less flexibility for mixed workload shapes&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/figure&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;The Auto Mode pricing&lt;/h2&gt;



&lt;p&gt;Do not model Auto Mode as &amp;ldquo;EC2 plus a generic percentage&amp;rdquo; unless you have pulled the actual rate for your region and instance mix. The official structure is:&lt;/p&gt;



&lt;pre class=&quot;wp-block-code&quot;&gt;&lt;code&gt;Total EKS Auto Mode cluster cost =
  EKS control plane cost
  + normal EC2 cost for instances launched and managed by Auto Mode
  + EKS Auto Mode management fee on those managed EC2 instances
  + normal surrounding AWS costs: EBS, load balancers, data transfer, CloudWatch, etc.

EKS Auto Mode management fee =
  sum of Auto Mode-managed instance runtime
  x the regional EKS Auto Mode management rate for each EC2 instance type&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The EKS Auto Mode fee is applied to the EC2 instances that Auto Mode launches and manages. It is billed in addition to the normal EC2 charge and in addition to the EKS control plane charge. AWS bills the Auto Mode fee per second with a one-minute minimum, and the charge is independent of whether the underlying EC2 capacity is On-Demand, Spot, covered by Reserved Instances, or covered by Compute Savings Plans.&lt;/p&gt;



&lt;p&gt;Do not treat the fee as a contractual flat percentage. The official pricing page describes it as a management fee that varies by EC2 instance type, and AWS pricing data is regional. The public pricing example for US West (Oregon) shows &lt;code&gt;c6a.2xlarge&lt;/code&gt; at $0.306/hour for EC2 plus $0.03672/hour for Auto Mode, &lt;code&gt;c6a.4xlarge&lt;/code&gt; at $0.612/hour plus $0.07344/hour, &lt;code&gt;m5a.2xlarge&lt;/code&gt; at $0.344/hour plus $0.04128/hour, and &lt;code&gt;m5a.xlarge&lt;/code&gt; at $0.172/hour plus $0.02064/hour. Those examples equal 12% of the listed On-Demand EC2 rate, but the safe formula for real planning is: &lt;code&gt;sum(instance-hours by instance type and region x published Auto Mode management rate)&lt;/code&gt;.&lt;/p&gt;



&lt;p&gt;The practical cost question is not &amp;ldquo;is there a premium?&amp;rdquo; There is. The useful question is whether the premium is lower than the engineering time, incident risk, and opportunity cost of operating node lifecycle yourself.&lt;/p&gt;



&lt;p&gt;For small teams, the answer may be yes even if the raw bill increases. For high-scale, cost-sensitive platforms, the answer needs real data: compare current EC2 waste, bin-packing efficiency, Spot usage, interruption rate, and platform maintenance time against an Auto Mode pilot.&lt;/p&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;When to use EKS Auto Mode&lt;/h2&gt;



&lt;p&gt;&lt;strong&gt;Use Auto Mode if:&lt;/strong&gt;&lt;/p&gt;



&lt;ul class=&quot;wp-block-list&quot;&gt;

&lt;li&gt;You run EKS on AWS and do not have a hard requirement to manage nodes yourself&lt;/li&gt;


&lt;li&gt;You do not require custom AMIs or custom node bootstrap logic&lt;/li&gt;


&lt;li&gt;You want to reduce the operations surface for node lifecycle management&lt;/li&gt;


&lt;li&gt;You want Karpenter-like provisioning without operating Karpenter yourself&lt;/li&gt;


&lt;li&gt;Your workloads are mostly stateless or disruption-tolerant&lt;/li&gt;


&lt;li&gt;Your observability, security, and storage agents are compatible with Auto Mode&lt;/li&gt;

&lt;/ul&gt;



&lt;p&gt;&lt;strong&gt;Stick with managed node groups if:&lt;/strong&gt;&lt;/p&gt;



&lt;ul class=&quot;wp-block-list&quot;&gt;

&lt;li&gt;Your organization requires internally certified or hardened AMIs&lt;/li&gt;


&lt;li&gt;You need specific kernel configuration, kubelet flags, bootstrap scripts, or host packages&lt;/li&gt;


&lt;li&gt;You depend on privileged DaemonSets or host-level security tooling that Auto Mode cannot support&lt;/li&gt;


&lt;li&gt;You are in a regulated environment where the node image supply chain must be owned internally&lt;/li&gt;


&lt;li&gt;Your platform team already operates Karpenter well and values the extra control&lt;/li&gt;

&lt;/ul&gt;



&lt;p&gt;&lt;strong&gt;Use Fargate if:&lt;/strong&gt;&lt;/p&gt;



&lt;ul class=&quot;wp-block-list&quot;&gt;

&lt;li&gt;You specifically want per-pod compute isolation&lt;/li&gt;


&lt;li&gt;Your workload does not need DaemonSets or host-level integrations&lt;/li&gt;


&lt;li&gt;You accept Fargate&amp;rsquo;s scheduling, networking, storage, and observability constraints&lt;/li&gt;


&lt;li&gt;You want to avoid managing EC2 capacity entirely for a narrow class of workloads&lt;/li&gt;

&lt;/ul&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;Migration from managed node groups&lt;/h2&gt;



&lt;p&gt;Migrating an existing cluster to Auto Mode is supported, but it is not a one-command operational migration. AWS supports enabling Auto Mode on existing clusters, but you must update the cluster IAM role permissions and trust policy, enable compute, block storage, and load balancing capabilities together, and meet required add-on versions when those add-ons are installed. AWS also calls out unsupported direct migrations for EBS volumes from the standard EBS CSI provisioner to the Auto Mode EBS CSI provisioner, existing load balancers from AWS Load Balancer Controller to Auto Mode, and clusters using alternative CNIs or other unsupported networking configurations. A conservative path looks like this:&lt;/p&gt;



&lt;ol class=&quot;wp-block-list&quot;&gt;

&lt;li&gt;Enable Auto Mode on a non-production cluster running Kubernetes 1.29 or greater.&lt;/li&gt;


&lt;li&gt;Inventory workloads by scheduling assumptions: node selectors, affinities, tolerations, topology spread, PDBs, privileged mode, hostPath, local storage, and DaemonSet dependencies.&lt;/li&gt;


&lt;li&gt;Create or select the relevant Auto Mode &lt;code&gt;NodeClass&lt;/code&gt; and &lt;code&gt;NodePool&lt;/code&gt; resources.&lt;/li&gt;


&lt;li&gt;Move a low-risk namespace first by changing selectors, tolerations, or labels so pods land on Auto Mode nodes.&lt;/li&gt;


&lt;li&gt;Watch scheduling, replacement, load balancer behavior, persistent volume provisioning, logging, metrics, and security events.&lt;/li&gt;


&lt;li&gt;T&lt;/li&gt;&lt;/ol&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/23253/eks-auto-mode-what-it-actually-changes-and-what-it-doesnt</guid>
<pubDate>Mon, 27 Jul 2026 10:00:00 +0000</pubDate>
</item>
<item>
<title>pnpm v12 Is Being Rewritten in Rust</title>
<link>https://coderlegion.com/22954/pnpm-v12-is-being-rewritten-in-rust</link>
<description>&lt;p&gt;Every package manager has a moment where you just wait. You add one dependency, or you pull a branch and run install, and there's a beat where the terminal sits there doing file I/O while you tab away. &lt;a rel=&quot;nofollow&quot; href=&quot;https://pnpm.io&quot;&gt;pnpm&lt;/a&gt; already made that beat shorter than most, staying ahead of npm and Yarn on most install scenarios thanks to its content-addressable store and hard-linked &lt;code&gt;node_modules&lt;/code&gt;. But it was still a Node.js program, paying the Node startup cost and pushing tens of thousands of file operations through a &lt;a rel=&quot;nofollow&quot; href=&quot;https://morello.dev/blog/five-things-you-might-not-know-about-javascript&quot;&gt;single JavaScript runtime&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;That is what changes in pnpm v12: it rewrites the install engine, the part that fetches and links packages, in &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.rust-lang.org&quot;&gt;Rust&lt;/a&gt;, and leaves the CLI, lockfile, and &lt;code&gt;node_modules&lt;/code&gt; layout untouched. It's the same move &lt;a rel=&quot;nofollow&quot; href=&quot;https://morello.dev/blog/typescript-7-is-here&quot;&gt;TypeScript just made with Go&lt;/a&gt;, aimed at a different bottleneck. And like that one, the interesting detail is how little is supposed to change for you.&lt;/p&gt;
&lt;h3 id=&quot;what-pnpm-v12-actually-is&quot;&gt;What pnpm v12 actually is&lt;/h3&gt;
&lt;p&gt;pnpm v12 is the same pnpm you already use, ported to Rust. pnpm has always been TypeScript running on Node.js, and the Rust work lives under the codename &lt;strong&gt;&lt;code&gt;pacquet&lt;/code&gt;&lt;/strong&gt;, whose repo describes it in one line: &quot;The official pnpm rewrite in Rust.&quot; The key word there is &lt;em&gt;rewrite&lt;/em&gt;, not &lt;em&gt;reimagining&lt;/em&gt;. It's a port. The flags, defaults, error codes, lockfile format, and &lt;code&gt;node_modules&lt;/code&gt; layout are all meant to match pnpm exactly.&lt;/p&gt;
&lt;p&gt;Zoltan Kochan, pnpm's creator, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/orgs/pnpm/discussions/11292&quot;&gt;put it plainly&lt;/a&gt; when the v12 plan came together: &quot;v12 should behave the same as v11, the biggest change will be the full rewrite to Rust.&quot; That's the frame to hold onto. This is a performance release wearing a major version number, not a redesign of how pnpm works.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;pacquet&lt;/code&gt; started as a separate experimental repo and &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/zkochan/status/2054966567692099943&quot;&gt;moved into the main pnpm monorepo&lt;/a&gt; in May 2026, so the TypeScript and Rust versions now evolve side by side. The eventual goal is for the Rust engine to be what you run.&lt;/p&gt;
&lt;h3 id=&quot;why-did-pnpm-choose-rust-&quot;&gt;Why did pnpm choose Rust?&lt;/h3&gt;
&lt;p&gt;The motivation isn't abstract. Two things were capping how fast a Node-based pnpm could go.&lt;/p&gt;
&lt;p&gt;The first is startup cost. Every invocation booted a Node.js runtime before it did any work. pnpm v12 ships as native per-platform binaries with &lt;a rel=&quot;nofollow&quot; href=&quot;https://pnpm.io/blog/releases/11.10&quot;&gt;no Node.js launcher&lt;/a&gt;, so a command pays no runtime bootstrap cost at all. On a fast, cached install where the actual work is milliseconds, that bootstrap was a real fraction of the wall-clock time.&lt;/p&gt;
&lt;p&gt;The second is the file I/O itself. A cold install on a large monorepo is really a lot of filesystem operations: fetching tarballs, unpacking them into the store, and hard-linking thousands of files into place. Running all of that through a single JavaScript thread leaves throughput on the table. Rust handles that fan-out natively, without the runtime sitting in the middle of every syscall.&lt;/p&gt;
&lt;p&gt;There's a softer reason too, and it's the same one that's driving the broader trend: the tooling to do this kind of rewrite has gotten dramatically better, and AI-assisted porting made a 1:1 translation of a large codebase far more tractable than it would have been a few years ago.&lt;/p&gt;
&lt;h3 id=&quot;how-much-faster-is-pnpm-v12-&quot;&gt;How much faster is pnpm v12?&lt;/h3&gt;
&lt;p&gt;pnpm publishes a &lt;a rel=&quot;nofollow&quot; href=&quot;https://pnpm.io/benchmarks&quot;&gt;benchmark suite&lt;/a&gt; comparing v11 against the Rust engine directly. As of this writing (July 2026), here's what it shows:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Scenario                          &lt;/th&gt;&lt;th&gt;pnpm v11 &lt;/th&gt;&lt;th&gt;pnpm (Rust)&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Everything warm (repeat install)  &lt;/td&gt;&lt;td&gt;381 ms   &lt;/td&gt;&lt;td&gt;12 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;node_modules present, no cache    &lt;/td&gt;&lt;td&gt;460 ms   &lt;/td&gt;&lt;td&gt;40 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Clean install (nothing cached)    &lt;/td&gt;&lt;td&gt;6.5 s    &lt;/td&gt;&lt;td&gt;2.2 s&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The warm case is everything already in place, so the install just re-verifies the tree, which is what happens on every CI job. That drops from 381ms to 12ms, roughly a 30x difference, and it's the number you'll feel most, because it runs constantly. The colder cases improve less dramatically but still meaningfully: a fully clean install with nothing cached goes from 6.5s to 2.2s, about 3x.&lt;/p&gt;
&lt;p&gt;Treat these as pnpm's own numbers on pnpm's own hardware, and expect your mileage to vary with disk speed and project size. But the direction is not subtle, and the team's claim is that the Rust engine is faster than v11 in every scenario they measured.&lt;/p&gt;
&lt;h3 id=&quot;it-s-already-rolling-out-incrementally&quot;&gt;It's already rolling out, incrementally&lt;/h3&gt;
&lt;p&gt;Here's the part that's easy to get wrong: pnpm v12 isn't a big-bang release. The Rust engine has been landing piece by piece through the v11 line, opt-in first.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;pnpm 11.2&lt;/strong&gt; let you opt into pacquet as the install backend for materialization only. Rust did the fetch and link, while pnpm still resolved dependencies and wrote the lockfile.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pnpm 11.7&lt;/strong&gt; went further: a standard install could be &lt;a rel=&quot;nofollow&quot; href=&quot;https://pnpm.io/blog/releases/11.7&quot;&gt;delegated to pacquet end-to-end&lt;/a&gt;, with resolution, lockfile write, and linking all in a single Rust pass.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pnpm 11.10&lt;/strong&gt; added &lt;code&gt;pnpm self-update next-12&lt;/code&gt;, so you can pull the v12 line and try the Rust binary directly.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In v12, the Rust engine for fetching and linking becomes the default rather than an opt-in. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/pnpm/pnpm/issues/11633&quot;&gt;official roadmap&lt;/a&gt; then works outward from there: first the headless frozen-lockfile installer, then full dependency resolution (&lt;code&gt;add&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;remove&lt;/code&gt;, catalogs, overrides, peers), and finally the remaining commands like &lt;code&gt;run&lt;/code&gt;, &lt;code&gt;store&lt;/code&gt;, and &lt;code&gt;publish&lt;/code&gt;. So even after v12 ships, parts of pnpm will still run through the TypeScript implementation for a while. The lockfile is what lets the two halves cooperate.&lt;/p&gt;
&lt;h3 id=&quot;what-changes-for-you-&quot;&gt;What changes for you?&lt;/h3&gt;
&lt;p&gt;The honest answer is: not much, on purpose. Same CLI, same lockfile, same &lt;code&gt;node_modules&lt;/code&gt;. The whole point of a 1:1 port is that your muscle memory and your CI scripts keep working.&lt;/p&gt;
&lt;p&gt;The breaking changes slated for v12 are &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/orgs/pnpm/discussions/11292&quot;&gt;deliberately small&lt;/a&gt;: removing some old migration code for git-hosted tarballs, rejecting unscoped auth settings, and deprecating the &lt;code&gt;$&lt;/code&gt; syntax in &lt;code&gt;overrides&lt;/code&gt; in favor of catalogs. If none of those describe your setup, a v12 upgrade should be close to a no-op.&lt;/p&gt;
&lt;h4 id=&quot;will-the-upgrade-break-your-ci-&quot;&gt;Will the upgrade break your CI?&lt;/h4&gt;
&lt;p&gt;Probably not, but the one thing worth checking is feature parity during the transition. Because the Rust engine is being ported incrementally, some newer or niche capabilities may lag the TypeScript version at any given moment. If your install depends on something specific (a particular linker mode, lockfile-verification settings, or &lt;code&gt;pnpm deploy&lt;/code&gt;), test the bump in an isolated &lt;a rel=&quot;nofollow&quot; href=&quot;https://morello.dev/blog/git-worktrees-are-underrated&quot;&gt;git worktree&lt;/a&gt; before you flip it on in CI, the same sandbox I used while &lt;a rel=&quot;nofollow&quot; href=&quot;https://morello.dev/blog/solidjs-2-async-data&quot;&gt;exploring SolidJS 2.0's async data model&lt;/a&gt;. This is moving quickly, so the right list of gaps is whatever the roadmap says on the day you upgrade.&lt;/p&gt;
&lt;h3 id=&quot;the-bigger-picture&quot;&gt;The bigger picture&lt;/h3&gt;
&lt;p&gt;pnpm is not doing this alone. The last few years have quietly rebuilt the JavaScript toolchain's foundations in native languages: &lt;a rel=&quot;nofollow&quot; href=&quot;https://biomejs.dev&quot;&gt;Biome&lt;/a&gt; for linting and formatting, &lt;a rel=&quot;nofollow&quot; href=&quot;https://oxc.rs&quot;&gt;Oxc&lt;/a&gt; for parsing and transforming, &lt;a rel=&quot;nofollow&quot; href=&quot;https://rolldown.rs&quot;&gt;Rolldown&lt;/a&gt; and Turbopack for bundling. TypeScript's compiler went to Go. Deno is Rust from the ground up. (Bun is the odd one out: it's written in Zig, not Rust, so don't lump it into the &quot;everything's Rust now&quot; story.)&lt;/p&gt;
&lt;p&gt;There's a competitive angle underneath it, too. Bun and Deno both ship as single fast binaries with package management built in, and a chunk of their appeal is exactly that no-runtime-bootstrap speed. A native-binary pnpm with no Node launcher closes that gap while keeping the thing that makes pnpm pnpm: the strict, content-addressable store and the non-flat &lt;code&gt;node_modules&lt;/code&gt; that catches phantom dependencies.&lt;/p&gt;
&lt;h3 id=&quot;should-you-upgrade-to-pnpm-v12-yet-&quot;&gt;Should you upgrade to pnpm v12 yet?&lt;/h3&gt;
&lt;p&gt;If you're waiting for a stable release: keep waiting, but not for long. As of mid-July 2026, v12 is in alpha. The latest tag is &lt;code&gt;v12.0.0-alpha.12&lt;/code&gt;, and the stable line is still v11.13.0. This is not something to point production CI at today.&lt;/p&gt;
&lt;p&gt;But if you want to feel the difference now, you already can. Run &lt;code&gt;pnpm self-update next-12&lt;/code&gt; on a branch, or opt into the pacquet backend on your current v11, and watch a warm install that used to take a beat return before you've finished reading the command. The type of release this is, where the language underneath gets an order of magnitude faster while everything on top stays exactly where you left it, is a rare and good one. pnpm was already the fast package manager. It's about to stop being one you ever wait on.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22954/pnpm-v12-is-being-rewritten-in-rust</guid>
<pubDate>Sat, 18 Jul 2026 22:37:54 +0000</pubDate>
</item>
<item>
<title>Unlocking the Power of Metal Recycling: MAXIM Machinery's Metal Crusher Machine</title>
<link>https://coderlegion.com/22797/unlocking-the-power-of-metal-recycling-maxim-machinerys-metal-crusher-machine</link>
<description>&lt;p&gt;When it comes to the industrial machinery industry, the need for effective and efficient recycling solutions has never been more critical. As industries worldwide pivot towards sustainable practices, the demand for robust machinery that can handle the recycling of metals has surged. Enter &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.hnmaxim.com/&quot;&gt;MAXIM Machinery's Metal Crusher Machine&lt;/a&gt;, a revolutionary piece of equipment designed to transform the way we approach metal recycling.&lt;/p&gt;
&lt;p&gt;The industrial landscape is shifting, with a heightened focus on sustainability and environmental responsibility. This shift has placed metal recycling at the forefront of industrial processes, necessitating machinery that can deliver on both efficiency and effectiveness. MAXIM Machinery's Metal Crusher Machine stands out as a beacon of innovation and reliability in this context, offering a range of features that cater to the diverse needs of the industry.&lt;/p&gt;
&lt;p&gt;At the core of the Metal Crusher Machine's appeal is its versatility. Capable of crushing a wide array of metals—from steel to aluminum—the machine is an indispensable asset for any facility engaged in metal recycling. Its design allows it to process varying metal sizes, turning scrap into valuable resources with ease. This adaptability is crucial in an industry where different materials often require specific handling and processing methods.&lt;/p&gt;
&lt;p&gt;The Metal Crusher Machine is engineered to meet the high demands of industrial metal shredding. One of the standout features of this machine is its ability to handle large volumes of scrap metal swiftly and effectively. This capability is particularly beneficial for industries dealing with substantial quantities of metal waste, ensuring that operations remain smooth and uninterrupted. The machine's robust construction means it can operate under the most challenging conditions, delivering consistent performance over time.&lt;/p&gt;
&lt;p&gt;In today's market, the need for machines that can process materials like steel and aluminum efficiently is more pressing than ever. Aluminum, in particular, is a metal that requires careful handling due to its lightweight nature and widespread use in industries ranging from automotive to packaging. The Metal Crusher Machine excels in aluminum can shredding, reducing bulky items into manageable sizes for recycling. This feature not only streamlines operations but also significantly reduces the environmental impact of metal waste.&lt;/p&gt;
&lt;p&gt;The importance of recycling extends beyond traditional metals, as industries now face the challenge of recycling components like lithium batteries. These batteries are integral to modern technology, yet they pose significant environmental risks if not disposed of correctly. A lithium battery recycling plant, equipped with MAXIM's Metal Crusher Machine, can effectively process these components, separating valuable materials for reuse while ensuring hazardous elements are safely contained. This capability highlights the machine's role in supporting the circular economy, where products are continually reused and recycled, minimizing waste.&lt;/p&gt;
&lt;p&gt;Scrap metal recycling equipment, such as the Metal Crusher Machine, is essential for industries looking to enhance their sustainability credentials. By investing in such technology, businesses can ensure that their operations not only comply with environmental regulations but also contribute positively to the planet. The machine's efficiency in processing scrap metal translates into reduced costs and increased profitability, making it a smart investment for any forward-thinking organization.&lt;/p&gt;
&lt;p&gt;Electric vehicle (EV) battery recycling is another area where the Metal Crusher Machine proves invaluable. As the world shifts towards electric vehicles, the need for effective recycling solutions for EV batteries becomes critical. These batteries contain materials that are both valuable and potentially harmful if not handled correctly. MAXIM Machinery's solution allows for the efficient breakdown and recycling of these components, ensuring that valuable materials are recovered and reused, while minimizing environmental harm.&lt;/p&gt;
&lt;p&gt;The demand for industrial metal shredder manufacturers is on the rise, driven by the need for machinery that can meet the growing recycling goals of various industries. MAXIM Machinery has established itself as a leader in this field, offering products that combine cutting-edge technology with practical applications. Their Metal Crusher Machine is a testament to their commitment to providing solutions that align with industry needs and environmental goals.&lt;/p&gt;
&lt;p&gt;In real-world applications, the Metal Crusher Machine has demonstrated its capacity to transform metal recycling operations. Consider a manufacturing plant producing significant amounts of metal waste daily. With the integration of MAXIM's Metal Crusher Machine, this plant can efficiently process its waste, turning what was once a disposal issue into a source of revenue. The machine's ability to quickly and effectively process various metals means that the plant can operate more sustainably, reducing its environmental footprint while boosting its bottom line.&lt;/p&gt;
&lt;p&gt;The benefits of using MAXIM Machinery's Metal Crusher Machine extend beyond its technical capabilities. The machine's design reflects a deep understanding of the practicalities involved in metal recycling. Its user-friendly interface and low maintenance requirements make it an attractive option for facilities looking to minimize downtime and operational costs. Furthermore, the machine's durability ensures that it remains a vital part of recycling operations for years to come, providing a reliable solution that evolves with industry demands.&lt;/p&gt;
&lt;p&gt;In conclusion, MAXIM Machinery's Metal Crusher Machine represents a significant advancement in the field of metal recycling. Its ability to handle various metals and its integration into recycling processes make it an essential tool for industries aiming to enhance their sustainable practices. As the industrial landscape continues to evolve, investing in robust and efficient recycling equipment like the Metal Crusher Machine is not just a choice—it's a necessity. With MAXIM Machinery leading the charge, the future of metal recycling looks promising, paving the way for a more sustainable and environmentally responsible industrial sector.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22797/unlocking-the-power-of-metal-recycling-maxim-machinerys-metal-crusher-machine</guid>
<pubDate>Thu, 16 Jul 2026 09:33:11 +0000</pubDate>
</item>
<item>
<title>pnpm v12 Is Being Rewritten in Rust</title>
<link>https://coderlegion.com/22762/pnpm-v12-is-being-rewritten-in-rust</link>
<description>&lt;p&gt;Every package manager has a moment where you just wait. You add one dependency, or you pull a branch and run install, and there's a beat where the terminal sits there doing file I/O while you tab away. &lt;a rel=&quot;nofollow&quot; href=&quot;https://pnpm.io&quot;&gt;pnpm&lt;/a&gt; already made that beat shorter than most, staying ahead of npm and Yarn on most install scenarios thanks to its content-addressable store and hard-linked &lt;code&gt;node_modules&lt;/code&gt;. But it was still a Node.js program, paying the Node startup cost and pushing tens of thousands of file operations through a &lt;a rel=&quot;nofollow&quot; href=&quot;https://morello.dev/blog/five-things-you-might-not-know-about-javascript&quot;&gt;single JavaScript runtime&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;That is what changes in pnpm v12: it rewrites the install engine, the part that fetches and links packages, in &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.rust-lang.org&quot;&gt;Rust&lt;/a&gt;, and leaves the CLI, lockfile, and &lt;code&gt;node_modules&lt;/code&gt; layout untouched. It's the same move &lt;a rel=&quot;nofollow&quot; href=&quot;https://morello.dev/blog/typescript-7-is-here&quot;&gt;TypeScript just made with Go&lt;/a&gt;, aimed at a different bottleneck. And like that one, the interesting detail is how little is supposed to change for you.&lt;/p&gt;
&lt;h3 id=&quot;what-pnpm-v12-actually-is-1&quot;&gt;What pnpm v12 actually is&lt;/h3&gt;
&lt;p&gt;pnpm v12 is the same pnpm you already use, ported to Rust. pnpm has always been TypeScript running on Node.js, and the Rust work lives under the codename &lt;strong&gt;&lt;code&gt;pacquet&lt;/code&gt;&lt;/strong&gt;, whose repo describes it in one line: &quot;The official pnpm rewrite in Rust.&quot; The key word there is &lt;em&gt;rewrite&lt;/em&gt;, not &lt;em&gt;reimagining&lt;/em&gt;. It's a port. The flags, defaults, error codes, lockfile format, and &lt;code&gt;node_modules&lt;/code&gt; layout are all meant to match pnpm exactly.&lt;/p&gt;
&lt;p&gt;Zoltan Kochan, pnpm's creator, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/orgs/pnpm/discussions/11292&quot;&gt;put it plainly&lt;/a&gt; when the v12 plan came together: &quot;v12 should behave the same as v11, the biggest change will be the full rewrite to Rust.&quot; That's the frame to hold onto. This is a performance release wearing a major version number, not a redesign of how pnpm works.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;pacquet&lt;/code&gt; started as a separate experimental repo and &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/zkochan/status/2054966567692099943&quot;&gt;moved into the main pnpm monorepo&lt;/a&gt; in May 2026, so the TypeScript and Rust versions now evolve side by side. The eventual goal is for the Rust engine to be what you run.&lt;/p&gt;
&lt;h3 id=&quot;why-did-pnpm-choose-rust--1&quot;&gt;Why did pnpm choose Rust?&lt;/h3&gt;
&lt;p&gt;The motivation isn't abstract. Two things were capping how fast a Node-based pnpm could go.&lt;/p&gt;
&lt;p&gt;The first is startup cost. Every invocation booted a Node.js runtime before it did any work. pnpm v12 ships as native per-platform binaries with &lt;a rel=&quot;nofollow&quot; href=&quot;https://pnpm.io/blog/releases/11.10&quot;&gt;no Node.js launcher&lt;/a&gt;, so a command pays no runtime bootstrap cost at all. On a fast, cached install where the actual work is milliseconds, that bootstrap was a real fraction of the wall-clock time.&lt;/p&gt;
&lt;p&gt;The second is the file I/O itself. A cold install on a large monorepo is really a lot of filesystem operations: fetching tarballs, unpacking them into the store, and hard-linking thousands of files into place. Running all of that through a single JavaScript thread leaves throughput on the table. Rust handles that fan-out natively, without the runtime sitting in the middle of every syscall.&lt;/p&gt;
&lt;p&gt;There's a softer reason too, and it's the same one that's driving the broader trend: the tooling to do this kind of rewrite has gotten dramatically better, and AI-assisted porting made a 1:1 translation of a large codebase far more tractable than it would have been a few years ago.&lt;/p&gt;
&lt;h3 id=&quot;how-much-faster-is-pnpm-v12--1&quot;&gt;How much faster is pnpm v12?&lt;/h3&gt;
&lt;p&gt;pnpm publishes a &lt;a rel=&quot;nofollow&quot; href=&quot;https://pnpm.io/benchmarks&quot;&gt;benchmark suite&lt;/a&gt; comparing v11 against the Rust engine directly. As of this writing (July 2026), here's what it shows:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Scenario                          &lt;/th&gt;&lt;th&gt;pnpm v11 &lt;/th&gt;&lt;th&gt;pnpm (Rust)&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Everything warm (repeat install)  &lt;/td&gt;&lt;td&gt;381 ms   &lt;/td&gt;&lt;td&gt;12 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;node_modules present, no cache    &lt;/td&gt;&lt;td&gt;460 ms   &lt;/td&gt;&lt;td&gt;40 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Clean install (nothing cached)    &lt;/td&gt;&lt;td&gt;6.5 s    &lt;/td&gt;&lt;td&gt;2.2 s&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The warm case is everything already in place, so the install just re-verifies the tree, which is what happens on every CI job. That drops from 381ms to 12ms, roughly a 30x difference, and it's the number you'll feel most, because it runs constantly. The colder cases improve less dramatically but still meaningfully: a fully clean install with nothing cached goes from 6.5s to 2.2s, about 3x.&lt;/p&gt;
&lt;p&gt;Treat these as pnpm's own numbers on pnpm's own hardware, and expect your mileage to vary with disk speed and project size. But the direction is not subtle, and the team's claim is that the Rust engine is faster than v11 in every scenario they measured.&lt;/p&gt;
&lt;h3 id=&quot;it-s-already-rolling-out-incrementally-1&quot;&gt;It's already rolling out, incrementally&lt;/h3&gt;
&lt;p&gt;Here's the part that's easy to get wrong: pnpm v12 isn't a big-bang release. The Rust engine has been landing piece by piece through the v11 line, opt-in first.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;pnpm 11.2&lt;/strong&gt; let you opt into pacquet as the install backend for materialization only. Rust did the fetch and link, while pnpm still resolved dependencies and wrote the lockfile.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pnpm 11.7&lt;/strong&gt; went further: a standard install could be &lt;a rel=&quot;nofollow&quot; href=&quot;https://pnpm.io/blog/releases/11.7&quot;&gt;delegated to pacquet end-to-end&lt;/a&gt;, with resolution, lockfile write, and linking all in a single Rust pass.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pnpm 11.10&lt;/strong&gt; added &lt;code&gt;pnpm self-update next-12&lt;/code&gt;, so you can pull the v12 line and try the Rust binary directly.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In v12, the Rust engine for fetching and linking becomes the default rather than an opt-in. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/pnpm/pnpm/issues/11633&quot;&gt;official roadmap&lt;/a&gt; then works outward from there: first the headless frozen-lockfile installer, then full dependency resolution (&lt;code&gt;add&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;remove&lt;/code&gt;, catalogs, overrides, peers), and finally the remaining commands like &lt;code&gt;run&lt;/code&gt;, &lt;code&gt;store&lt;/code&gt;, and &lt;code&gt;publish&lt;/code&gt;. So even after v12 ships, parts of pnpm will still run through the TypeScript implementation for a while. The lockfile is what lets the two halves cooperate.&lt;/p&gt;
&lt;h3 id=&quot;what-changes-for-you--1&quot;&gt;What changes for you?&lt;/h3&gt;
&lt;p&gt;The honest answer is: not much, on purpose. Same CLI, same lockfile, same &lt;code&gt;node_modules&lt;/code&gt;. The whole point of a 1:1 port is that your muscle memory and your CI scripts keep working.&lt;/p&gt;
&lt;p&gt;The breaking changes slated for v12 are &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/orgs/pnpm/discussions/11292&quot;&gt;deliberately small&lt;/a&gt;: removing some old migration code for git-hosted tarballs, rejecting unscoped auth settings, and deprecating the &lt;code&gt;$&lt;/code&gt; syntax in &lt;code&gt;overrides&lt;/code&gt; in favor of catalogs. If none of those describe your setup, a v12 upgrade should be close to a no-op.&lt;/p&gt;
&lt;h4 id=&quot;will-the-upgrade-break-your-ci--1&quot;&gt;Will the upgrade break your CI?&lt;/h4&gt;
&lt;p&gt;Probably not, but the one thing worth checking is feature parity during the transition. Because the Rust engine is being ported incrementally, some newer or niche capabilities may lag the TypeScript version at any given moment. If your install depends on something specific (a particular linker mode, lockfile-verification settings, or &lt;code&gt;pnpm deploy&lt;/code&gt;), test the bump in an isolated &lt;a rel=&quot;nofollow&quot; href=&quot;https://morello.dev/blog/git-worktrees-are-underrated&quot;&gt;git worktree&lt;/a&gt; before you flip it on in CI. This is moving quickly, so the right list of gaps is whatever the roadmap says on the day you upgrade.&lt;/p&gt;
&lt;h3 id=&quot;the-bigger-picture-1&quot;&gt;The bigger picture&lt;/h3&gt;
&lt;p&gt;pnpm is not doing this alone. The last few years have quietly rebuilt the JavaScript toolchain's foundations in native languages: &lt;a rel=&quot;nofollow&quot; href=&quot;https://biomejs.dev&quot;&gt;Biome&lt;/a&gt; for linting and formatting, &lt;a rel=&quot;nofollow&quot; href=&quot;https://oxc.rs&quot;&gt;Oxc&lt;/a&gt; for parsing and transforming, &lt;a rel=&quot;nofollow&quot; href=&quot;https://rolldown.rs&quot;&gt;Rolldown&lt;/a&gt; and Turbopack for bundling. TypeScript's compiler went to Go. Deno is Rust from the ground up. (Bun is the odd one out: it's written in Zig, not Rust, so don't lump it into the &quot;everything's Rust now&quot; story.)&lt;/p&gt;
&lt;p&gt;There's a competitive angle underneath it, too. Bun and Deno both ship as single fast binaries with package management built in, and a chunk of their appeal is exactly that no-runtime-bootstrap speed. A native-binary pnpm with no Node launcher closes that gap while keeping the thing that makes pnpm pnpm: the strict, content-addressable store and the non-flat &lt;code&gt;node_modules&lt;/code&gt; that catches phantom dependencies.&lt;/p&gt;
&lt;h3 id=&quot;should-you-upgrade-to-pnpm-v12-yet--1&quot;&gt;Should you upgrade to pnpm v12 yet?&lt;/h3&gt;
&lt;p&gt;If you're waiting for a stable release: keep waiting, but not for long. As of mid-July 2026, v12 is in alpha. The latest tag is &lt;code&gt;v12.0.0-alpha.12&lt;/code&gt;, and the stable line is still v11.13.0. This is not something to point production CI at today.&lt;/p&gt;
&lt;p&gt;But if you want to feel the difference now, you already can. Run &lt;code&gt;pnpm self-update next-12&lt;/code&gt; on a branch, or opt into the pacquet backend on your current v11, and watch a warm install that used to take a beat return before you've finished reading the command. The type of release this is, where the language underneath gets an order of magnitude faster while everything on top stays exactly where you left it, is a rare and good one. pnpm was already the fast package manager. It's about to stop being one you ever wait on.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22762/pnpm-v12-is-being-rewritten-in-rust</guid>
<pubDate>Wed, 15 Jul 2026 15:13:28 +0000</pubDate>
</item>
<item>
<title> FNPM Just Became Invisible (In the Good Way): Transparent Alias, Better Tests &amp; a Smoother Experience</title>
<link>https://coderlegion.com/22063/fnpm-just-became-invisible-the-good-transparent-alias-better-tests-smoother-experience</link>
<description>&lt;p&gt;There are moments when you stop, look at your terminal, and think:&lt;br&gt;
&lt;em&gt;“Why does something as simple as managing packages in JavaScript has to be… so dramatic?”&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;If you've been around the Node.js ecosystem for more than 5 minutes, you know exactly what I mean.&lt;/p&gt;
&lt;p&gt;Well, &lt;strong&gt;FNPM just got its biggest update ever&lt;/strong&gt;, and no, it’s not “just another technical improvement.” This update is about giving you the experience we all secretly wanted: using your favorite package manager &lt;em&gt;while something way smarter runs behind the scenes&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Let me explain.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;before-npm-pnpm-yarn-drama-&quot;&gt;Before: npm, pnpm, yarn… drama.&lt;/h3&gt;
&lt;h3 id=&quot;now-use-whatever-you-want-fnpm-does-the-hard-work-&quot;&gt;Now: use whatever you want. FNPM does the hard work.&lt;/h3&gt;
&lt;p&gt;The biggest change (and my personal favorite) is the new &lt;strong&gt;transparent alias system&lt;/strong&gt;.&lt;br&gt;
This means you can now type:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;pnpm install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;…and behind the scenes, &lt;strong&gt;FNPM is actually doing the job&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Why is this so massive?&lt;/p&gt;
&lt;p&gt;Because for the first time, an alternative package manager can &lt;em&gt;sneak into&lt;/em&gt; (yeah, sounds weird but it’s beautiful) your workflow &lt;strong&gt;without asking you to change anything&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;If your team uses &lt;code&gt;pnpm&lt;/code&gt;, perfect.&lt;br&gt;
If you prefer FNPM’s model and how it handles things, also perfect.&lt;br&gt;
If your environment is mixed… well, guess what? It works too.&lt;/p&gt;
&lt;p&gt;FNPM now behaves as a &lt;strong&gt;real drop-in replacement&lt;/strong&gt;, like &lt;em&gt;for real real&lt;/em&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-does-this-alias-magic-works-&quot;&gt;How does this alias magic works?&lt;/h3&gt;
&lt;p&gt;FNPM now includes a smart &lt;strong&gt;hook system&lt;/strong&gt; that intercepts the traditional package manager commands and transforms them into FNPM operations.&lt;/p&gt;
&lt;p&gt;In short:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It detects what you want (&lt;code&gt;install&lt;/code&gt;, &lt;code&gt;add&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;run&lt;/code&gt;, etc.).  &lt;/li&gt;
&lt;li&gt;It maps it to FNPM’s internal engine.  &lt;/li&gt;
&lt;li&gt;It keeps the CLI experience you already know.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;All without breaking scripts, without changing your mental model, and without asking you to learn new commands (because, honestly, nobody wants that anymore ).&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;better-test-coverage-and-now-actually-cross-platform-&quot;&gt;Better test coverage (and now &lt;em&gt;actually&lt;/em&gt; cross-platform)&lt;/h3&gt;
&lt;p&gt;Another big step forward is the &lt;strong&gt;improved test coverage&lt;/strong&gt;, which now behaves consistently across systems:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Linux  &lt;/li&gt;
&lt;li&gt;macOS  &lt;/li&gt;
&lt;li&gt;Windows (yes, Windows got some love this time )&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Error messages, symlink behavior, path formatting… all those cross-platform headaches?&lt;br&gt;
FNPM now handles them.&lt;/p&gt;
&lt;p&gt;This means FNPM isn’t just “usable” on Windows —&lt;br&gt;
it’s &lt;strong&gt;reliable&lt;/strong&gt;, &lt;strong&gt;predictable&lt;/strong&gt;, and &lt;strong&gt;CI-friendly&lt;/strong&gt; across all platforms.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-other-cool-improvements-worth-mentioning&quot;&gt;️ Other cool improvements worth mentioning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code&gt;fnpm source&lt;/code&gt; command for shell integration  &lt;/li&gt;
&lt;li&gt;GitHub Workflows + issue templates for better contributing  &lt;/li&gt;
&lt;li&gt;Updated dependencies (thanks Dependabot )  &lt;/li&gt;
&lt;li&gt;Cleaner project structure with better dev tooling  &lt;/li&gt;
&lt;li&gt;A more complete test suite for future expansions  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;FNPM keeps growing with a focus on simplicity + power, not complexity.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;a-real-example-turning-pnpm-into-fnpm-without-anyone-noticing-&quot;&gt;A real example: turning &lt;code&gt;pnpm&lt;/code&gt; into FNPM (without anyone noticing)&lt;/h3&gt;
&lt;p&gt;Here’s a real example of how easy this is.&lt;br&gt;
I swear the first time you run it, you’ll feel that “oh wow” moment .&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;(base) ➜  awesome-project fnpm setup pnpm  
Selected package manager: pnpm  

 FNPM hooks created successfully!

Setup Instructions:  
Unix/Linux/macOS:  
  1. Source the setup script:  
     source .fnpm/setup.sh  
  2. Or add to your shell profile (~/.bashrc, ~/.zshrc):  
     echo 'eval &quot;$(fnpm source)&quot;' &amp;gt;&amp;gt; ~/.zshrc  

Usage:  
  • Now you can use pnpm directly:  
    pnpm add some-package  
    pnpm install  
    pnpm run my-script  
  • Commands will be automatically redirected to fnpm  

Note:  
  • Hooks only work in directories with .fnpm configuration  
  • Use full path to bypass: $(which pnpm) command  
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reload your shell:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;(base) ➜  awesome-project echo 'eval &quot;$(fnpm source)&quot;' &amp;gt;&amp;gt; ~/.zshrc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now try any &lt;code&gt;pnpm&lt;/code&gt; command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;(base) ➜  awesome-project pnpm --help
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You’ll see something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt; This pnpm command is intercepted by FNPM  
Available commands:  
  install, add, remove, run, list, update, cache, clean  

Use 'fnpm --help' for more information  
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; Boom.&lt;br&gt;
From that moment on, you keep using &lt;code&gt;pnpm&lt;/code&gt; like always…&lt;br&gt;
but FNPM is the one doing the heavy lifting behind the curtain.&lt;/p&gt;
&lt;p&gt;No workflow changes.&lt;br&gt;
No broken scripts.&lt;br&gt;
No new habits.&lt;br&gt;
Just smooth developer life.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-in-short-fnpm-is-no-longer-used-it-s-felt&quot;&gt;✨ In short: FNPM is no longer “used”… it’s &lt;em&gt;felt&lt;/em&gt;&lt;/h3&gt;
&lt;p&gt;With this transparent alias system, FNPM stops being a command you remember to type, and becomes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;fast  &lt;/li&gt;
&lt;li&gt;consistent  &lt;/li&gt;
&lt;li&gt;cross-platform  &lt;/li&gt;
&lt;li&gt;and completely invisible when you want it to be  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If it was useful before, now it feels… &lt;em&gt;kinda magical&lt;/em&gt;, honestly.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;ps-we-used-ai-to-help-us-write-this-article-because-great-tools-deserve-great-storytelling-&quot;&gt;PS: we used AI to help us write this article — because great tools deserve great storytelling.&lt;/h4&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://fnpm-docs.vercel.app/&quot;&gt;Documentation here&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22063/fnpm-just-became-invisible-the-good-transparent-alias-better-tests-smoother-experience</guid>
<pubDate>Tue, 07 Jul 2026 17:12:02 +0000</pubDate>
</item>
<item>
<title>FNPM: The Package Manager Drama Ends Here </title>
<link>https://coderlegion.com/21776/fnpm-the-package-manager-drama-ends-here</link>
<description>&lt;h4 id=&quot;fnpm-the-package-manager-drama-ends-here&quot;&gt;FNPM: The Package Manager Drama Ends Here&lt;/h4&gt;
&lt;p&gt;Hey devs!  Tired of the endless debates over package managers? Ever wished for a solution that brings peace to the package manager wars? Meet FNPM – because life's too short for package manager drama! &lt;/p&gt;
&lt;p&gt;I work at &lt;a rel=&quot;nofollow&quot; href=&quot;https://konstruct.io&quot;&gt;Konstruct.io&lt;/a&gt; we have free Fridays, and this idea was stuck in my mind for a while. I finally took advantage of AI to bring it to life!  So please give us ⭐️ on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/konstructio/kubefirst&quot;&gt;konstructio/kubefirst&lt;/a&gt;, a great and amazing way to setup your kubernetes infrastructure following gitOps Philosophy.&lt;/p&gt;
&lt;h5 id=&quot;what-is-fnpm-&quot;&gt;What is FNPM?&lt;/h5&gt;
&lt;p&gt;FNPM stands for &lt;strong&gt;Fuck NPM&lt;/strong&gt;. It's a tool designed to end the eternal conflicts between npm, yarn, pnpm, and others by supporting them all seamlessly. Think of it as the peace treaty in the package manager soap opera. ️&lt;/p&gt;
&lt;h5 id=&quot;why-should-you-care-&quot;&gt;Why Should You Care?&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Universal Support &lt;/strong&gt; – FNPM supports multiple package managers, including npm, yarn, pnpm, bun, and deno. Use your favorite; FNPM has your back!&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One Lock File to Rule Them All &lt;/strong&gt; – No more lock file conflicts! FNPM ensures consistent dependencies across your team, regardless of individual preferences.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Happy CI/CD &lt;/strong&gt; – Standardized workflows and consistent builds mean no more &quot;works on my machine&quot; excuses.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Team Harmony &lt;/strong&gt; – Let developers be developers. Stop the package manager wars and focus on building awesome stuff!&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;how-to-get-started&quot;&gt;How to Get Started&lt;/h5&gt;
&lt;p&gt;Getting FNPM up and running is super simple:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;/bin/bash -c &quot;$(curl -fsSL https://raw.githubusercontent.com/ideascoldigital/fnpm/refs/heads/main/install.sh)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then, just use it like you would with your preferred package manager:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fnpm setup
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And boom!  Your dependencies are managed without the drama. &lt;/p&gt;
&lt;h5 id=&quot;basic-commands&quot;&gt;Basic Commands&lt;/h5&gt;
&lt;p&gt;Here's a quick reference of basic commands supported across all package managers:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Action               &lt;/th&gt;&lt;th&gt;FNPM Command            &lt;/th&gt;&lt;th&gt;What it does&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Install dependencies &lt;/td&gt;&lt;td&gt;&lt;code&gt;fnpm install&lt;/code&gt;          &lt;/td&gt;&lt;td&gt;Installs all project dependencies&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Add a package        &lt;/td&gt;&lt;td&gt;&lt;code&gt;fnpm add &amp;lt;package&amp;gt;&lt;/code&gt;    &lt;/td&gt;&lt;td&gt;Adds a new package to your project&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Remove a package     &lt;/td&gt;&lt;td&gt;&lt;code&gt;fnpm remove &amp;lt;package&amp;gt;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Removes a package from your project&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Update packages      &lt;/td&gt;&lt;td&gt;&lt;code&gt;fnpm update&lt;/code&gt;           &lt;/td&gt;&lt;td&gt;Updates packages to their latest versions&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Run a script         &lt;/td&gt;&lt;td&gt;&lt;code&gt;fnpm run &amp;lt;script&amp;gt;&lt;/code&gt;     &lt;/td&gt;&lt;td&gt;Runs a script defined in package.json&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Clean cache          &lt;/td&gt;&lt;td&gt;&lt;code&gt;fnpm clean&lt;/code&gt;            &lt;/td&gt;&lt;td&gt;Cleans the package manager cache&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;List packages        &lt;/td&gt;&lt;td&gt;&lt;code&gt;fnpm list&lt;/code&gt;             &lt;/td&gt;&lt;td&gt;Lists installed packages&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h5 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h5&gt;
&lt;p&gt;FNPM is here to bring harmony to your development workflow. It's easy to install, easy to use, and might just save you from the package manager drama. ️&lt;/p&gt;
&lt;p&gt;Check out the official docs here  &lt;a rel=&quot;nofollow&quot; href=&quot;https://fnpm-docs.vercel.app/&quot;&gt;FNPM Documentation&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Have you tried FNPM? Let me know what you think! ✨&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21776/fnpm-the-package-manager-drama-ends-here</guid>
<pubDate>Thu, 02 Jul 2026 17:18:53 +0000</pubDate>
</item>
<item>
<title>I Doubled My Servers and Wrecked My Availability</title>
<link>https://coderlegion.com/21448/i-doubled-my-servers-and-wrecked-my-availability</link>
<description>&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/10g0fapwadet1hbtfb4h.png&quot; alt=&quot;Cover Image&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“Reliability is the most important feature of any system.” — Bill Gates&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Why does your bank never show the wrong balance, but your favorite app sometimes takes a few seconds to update? Why do some systems feel fast but crash under load, while others feel slower but never fail?&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Performance answers: “How fast is the system right now?”&lt;br&gt;
Scalability answers: “What happens when demand increases?”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;These questions point to a fundamental tension in system design: &lt;strong&gt;performance vs scalability&lt;/strong&gt;.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;A system can improve performance metrics like throughput and latency, yet fail to scale under load. This article demonstrates exactly that.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In this article, we’ll explore this trade-off through a simple analogy, and then connect it to real-world systems using measurable metrics like latency, throughput, and error rate.&lt;/p&gt;
&lt;h3 id=&quot;the-burger-shop-analogy&quot;&gt;The Burger Shop Analogy&lt;/h3&gt;
&lt;p&gt;Imagine a small burger shop run by a single worker, let’s call them &lt;strong&gt;A&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Let’s assume the following things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Burgers are prepared automatically by machines, so A only handles taking and serving orders.&lt;/li&gt;
&lt;li&gt;Finished burgers are placed in a shared box, from which A picks them up and delivers them to customers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We intentionally ignore preparation time and assume burgers are instantly ready. This allows us to focus purely on how requests are handled, rather than how they are produced.&lt;/p&gt;
&lt;h4 id=&quot;the-single-worker-system&quot;&gt;The Single Worker System&lt;/h4&gt;
&lt;p&gt;In this setup, a single worker handles all incoming requests.&lt;/p&gt;
&lt;p&gt;When you enter the shop, you join a single queue and wait your turn before placing an order. Every request is processed sequentially (one at a time).&lt;/p&gt;
&lt;p&gt;A few key observations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Single queue, sequential processing&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;With only one line, every customer must wait for those ahead of them. This results in higher waiting time, which translates to &lt;strong&gt;high latency&lt;/strong&gt; and &lt;strong&gt;low throughput&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No contention&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Since only one request is handled at a time, there is no competition for shared resources. The system operates without conflicts or synchronization issues.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Efficient resource usage&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Resources are used strictly on demand. Each burger is prepared only when needed, with no excess consumption or overhead.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;what-does-this-imply-&quot;&gt;What does this imply?&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;High latency&lt;/strong&gt; → Each request experiences delay due to queuing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Low throughput&lt;/strong&gt; → Only one request is processed at any given time&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No contention&lt;/strong&gt; → No conflicts between concurrent operations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Despite its limitations, this system is &lt;strong&gt;remarkably stable&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;There are no race conditions, no lost work, and no unexpected failures. Every request is handled in a predictable and controlled manner.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Slow, but predictable.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;scaling-the-system&quot;&gt;Scaling the System&lt;/h4&gt;
&lt;p&gt;So far, the limitation has been clear: a single worker handling all requests.&lt;/p&gt;
&lt;p&gt;To improve this, we introduce more machines. Instead of relying on a single machine, worker A can now distribute incoming orders across multiple machines.&lt;/p&gt;
&lt;p&gt;Let’s examine what changes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Parallel processing&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;With multiple machines available, several orders can be handled simultaneously. This increases the number of requests processed per unit time, leading to &lt;strong&gt;higher throughput&lt;/strong&gt; and &lt;strong&gt;reduced latency&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Decision overhead (routing)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Worker A must now decide which machine should handle each request. This introduces a small coordination overhead — similar to how a load balancer (e.g., Nginx) routes requests in real systems.&lt;/p&gt;
&lt;p&gt;For simplicity, we will ignore this overhead in our analysis.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;what-happens-to-the-metrics-&quot;&gt;What happens to the metrics?&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Multiple orders can be processed simultaneously.&lt;/li&gt;
&lt;li&gt;Waiting time decreases&lt;/li&gt;
&lt;li&gt;Throughput increases&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This approach is known as &lt;strong&gt;horizontal scaling&lt;/strong&gt;, increasing system capacity by adding more resources rather than making a single component more powerful.&lt;/p&gt;
&lt;h4 id=&quot;the-hidden-problem&quot;&gt;The Hidden Problem&lt;/h4&gt;
&lt;p&gt;So far, scaling seems like a clear improvement.&lt;/p&gt;
&lt;p&gt;But consider the shared burger box , the place where all machines deposit finished burgers.&lt;/p&gt;
&lt;p&gt;What happens if multiple machines try to place burgers into the box at the same time?&lt;/p&gt;
&lt;p&gt;This introduces two critical failure scenarios:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Lost Work&lt;/p&gt;
&lt;p&gt;One burger makes it into the box, but another falls to the ground.&lt;/p&gt;
&lt;p&gt;That burger is wasted, and the customer never receives their order.&lt;/p&gt;
&lt;p&gt;In system terms, this is a &lt;strong&gt;failed request&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;System Blockage&lt;/p&gt;
&lt;p&gt;Both burgers get stuck at the entrance of the box, blocking it completely.&lt;/p&gt;
&lt;p&gt;Now no further burgers can be placed.&lt;/p&gt;
&lt;p&gt;In system terms, this resembles &lt;strong&gt;contention or resource locking&lt;/strong&gt;, where the system slows down or stops entirely.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;What Changed ?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;By introducing multiple machines, we improved performance:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Higher throughput, lower latency&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Multiple burgers can now be prepared simultaneously. This increases throughput and reduces waiting time for customers, improving overall performance.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Increased coordination and state management&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;With two machines handling requests, worker A must decide where each order goes. Both machines can process requests, so their state must be tracked and logs must be maintained. This introduces coordination overhead.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Inefficiency due to lost work&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Some burgers may fall to the ground before reaching the box. These orders are wasted, and the customer never receives their burger. In system terms, this represents failed or lost requests.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Unpredictable latency&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Consider a customer waiting for an order that never arrives. If they leave after 30 seconds, that request now has very high latency. Even if other customers are served quickly, overall latency becomes inconsistent and harder to predict.This is the core trade-off.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;In system terms, we have introduced:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Contention for shared resources&lt;/li&gt;
&lt;li&gt;Increased system complexity&lt;/li&gt;
&lt;li&gt;Possibility of failure&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-core-trade-off&quot;&gt;The Core Trade-off&lt;/h4&gt;
&lt;p&gt;This is the fundamental trade-off in system design.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Some customers are served faster, but others may never get their order.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;performance&quot;&gt;Performance&lt;/h3&gt;
&lt;p&gt;Performance refers to how effectively a system executes tasks within a given time frame. It is typically evaluated in terms of speed, responsiveness, and how efficiently system resources are utilized under load.&lt;/p&gt;
&lt;p&gt;To understand and measure performance, we rely on a few key metrics:&lt;/p&gt;
&lt;h4 id=&quot;latency&quot;&gt;Latency&lt;/h4&gt;
&lt;p&gt;Latency is the time it takes for a system to respond to a request.&lt;/p&gt;
&lt;p&gt;In simpler terms, it measures how long a user has to wait after initiating an action.&lt;/p&gt;
&lt;h4 id=&quot;throughput&quot;&gt;Throughput&lt;/h4&gt;
&lt;p&gt;Throughput represents the number of requests or transactions a system can handle per unit of time.&lt;/p&gt;
&lt;p&gt;It reflects the system’s capacity to process workload at scale.&lt;/p&gt;
&lt;h4 id=&quot;resource-utilization&quot;&gt;Resource Utilization&lt;/h4&gt;
&lt;p&gt;Resource utilization measures how much of the system’s resources, such as CPU, memory, and network bandwidth are consumed while handling requests.&lt;/p&gt;
&lt;p&gt;Efficient systems aim to maximize output while minimizing unnecessary resource consumption.&lt;/p&gt;
&lt;h4 id=&quot;efficiency&quot;&gt;Efficiency&lt;/h4&gt;
&lt;p&gt;Efficiency describes how well a system converts resources into useful work.&lt;/p&gt;
&lt;p&gt;A highly efficient system achieves high throughput and low latency while using minimal resources.&lt;/p&gt;
&lt;h3 id=&quot;scalability&quot;&gt;Scalability&lt;/h3&gt;
&lt;p&gt;Scalability refers to a system’s ability to handle increasing load by proportionally utilizing additional resources, while maintaining acceptable performance.&lt;/p&gt;
&lt;p&gt;In other words, a scalable system does not degrade significantly as the number of users or requests grows, it adapts by efficiently distributing the workload.&lt;/p&gt;
&lt;p&gt;To evaluate scalability, we observe how key performance metrics behave under increasing load, including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Latency under load&lt;/li&gt;
&lt;li&gt;Throughput under load&lt;/li&gt;
&lt;li&gt;Resource utilization&lt;/li&gt;
&lt;li&gt;Error rates (failures, timeouts)&lt;/li&gt;
&lt;li&gt;Number of concurrent users supported&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A system is considered scalable if these metrics remain stable or degrade gracefully as demand increases.&lt;/p&gt;
&lt;h4 id=&quot;availability&quot;&gt;Availability&lt;/h4&gt;
&lt;p&gt;Availability is the measure of a system’s ability to respond to requests within an acceptable time frame.&lt;/p&gt;
&lt;p&gt;A system is considered available if it is operational and capable of returning responses, even under load. High availability systems are designed to minimize downtime and ensure that users can reliably access the service.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“A system can be highly performant but not scalable. for example, a single powerful server may respond quickly, but fail when user load increases.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;the-monolith-vs-the-load-balanced-fleet-a-vs-2a-s-&quot;&gt;The Monolith vs. The Load-Balanced Fleet (A vs 2A’s)&lt;/h3&gt;
&lt;p&gt;To move beyond theory, we need to observe how a system behaves when pushed to its absolute limits. To test the relationship between compute scaling, database bottlenecks, and overall availability, an A/B load test was constructed.&lt;/p&gt;
&lt;p&gt;The code for the experiments can be found in attached github repository: {% github &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/c0d3l0v3r-HeHe/performance-vs-scalability-experiment&quot;&gt;https://github.com/c0d3l0v3r-HeHe/performance-vs-scalability-experiment&lt;/a&gt; %}&lt;/p&gt;
&lt;h3 id=&quot;assumptions&quot;&gt;Assumptions&lt;/h3&gt;
&lt;p&gt;Before running the load test, the architecture was built on three core hypotheses:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Linear Scalability:&lt;/strong&gt; Doubling the compute resources (adding a second application server) will linearly increase the system’s maximum throughput.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Testing Default Database Limits:&lt;/strong&gt; We hypothesized that the default connection pool of a single MongoDB instance would be sufficient to handle the concurrent load of doubled compute resources.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Load Balancer Penalty is Negligible:&lt;/strong&gt; The network hop introduced by routing traffic through a reverse proxy will be outweighed by the massive gains in request processing speed.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;setup-methodology&quot;&gt;Setup Methodology&lt;/h3&gt;
&lt;p&gt;The experiment was conducted locally using Docker to containerize and isolate the environments. The testing tool, k6, was configured to inject a stepped load simulating aggressive traffic spikes: starting at 100 concurrent virtual users and ramping up to 2,000 users over an 80-second window.&lt;/p&gt;
&lt;p&gt;The workload itself was a mixed Read/Write operation. Every request forced the application to write a new document to the database and immediately read a document back, simulating a standard transactional endpoint.&lt;/p&gt;
&lt;p&gt;We tested three distinct architectures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Architecture A (The Baseline):&lt;/strong&gt; A single Node.js/Express application connected directly to a standalone MongoDB instance. This represents a traditional, unscaled monolith.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Architecture 2A (The Distributed Compute):&lt;/strong&gt; Two identical Node.js/Express applications sitting behind an Nginx load balancer. Nginx utilized a round-robin strategy to distribute incoming k6 traffic across both nodes. Crucially, both application nodes were wired to the exact same single MongoDB instance.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const express = require(&quot;express&quot;);
const mongoose = require(&quot;mongoose&quot;);
const os = require(&quot;os&quot;);

const app = express();


mongoose.connect(&quot;mongodb://mongo:27017/testdb&quot;, {
  maxPoolSize: 50,
});

const TestSchema = new mongoose.Schema({
  value: Number,
});

const Test = mongoose.model(&quot;Test&quot;, TestSchema);

app.get(&quot;/&quot;, async (req, res) =&amp;gt; {
  const start = Date.now();

  try {
    await Test.create({ value: Math.random() });
    await Test.findOne();

    const latency = Date.now() - start;

    res.json({
      latency,
      server: os.hostname(), 
    });
  } catch (err) {
    res.status(500).json({ error: &quot;DB error&quot; });
  }
});

app.listen(3000, () =&amp;gt; console.log(`Server running on ${os.hostname()}`));
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;results&quot;&gt;Results&lt;/h4&gt;
&lt;p&gt;The k6 load test yielded drastically different saturation points for the two architectures.&lt;/p&gt;
&lt;p&gt;These reports are present as the .html files in the github respository.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Architecture A (Single Node Baseline):&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/xy7rb03cgxh4kec3dtq0.png&quot; alt=&quot;The latency trade-off. With a single Node.js event loop acting as a natural throttle, the p95 latency sat at ~796ms. The system was relatively slow under heavy load, but perfectly stable.&quot;&gt;&lt;/p&gt;
&lt;p&gt;The latency trade-off. With a single Node.js event loop acting as a natural throttle, the p95 latency sat at ~796ms. The system was relatively slow under heavy load, but perfectly stable.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/gpjcg2yl4lcfzgkh8ey6.png&quot; alt=&quot;Baseline throughput. The single-node architecture safely processed a total of 176,584 requests, hitting a hard bottleneck at roughly 2,193 requests per second.&quot;&gt;&lt;/p&gt;
&lt;p&gt;Baseline throughput. The single-node architecture safely processed a total of 176,584 requests, hitting a hard bottleneck at roughly 2,193 requests per second.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/vnaupmlr7zaamfuv26lo.png&quot; alt=&quot;_100% Availability. Because the compute layer could not overwhelm the database connection pool, every single read/write transaction was successfully completed without a single dropped request._&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;100% Availability. Because the compute layer could not overwhelm the database connection pool, every single read/write transaction was successfully completed without a single dropped request.&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Total Requests Processed:&lt;/strong&gt; 176,584&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Throughput:&lt;/strong&gt; 2,193 requests/second&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Latency (p95):&lt;/strong&gt; ~796 ms&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Failure Rate:&lt;/strong&gt; 0.00% (100% Success)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Architecture 2A (Distributed Compute):&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/y6i59v9irnffmubrmxu6.png&quot; alt=&quot;The smoking gun. While the p95 latency for successful requests actually dropped to ~492ms, the maximum latency spiked to an unusable 36 seconds. The explicit HTTP 500 errors confirm the single MongoDB instance was pushed into connection exhaustion.&quot;&gt;&lt;/p&gt;
&lt;p&gt;The smoking gun. While the p95 latency for successful requests actually dropped to ~492ms, the maximum latency spiked to an unusable 36 seconds. The explicit HTTP 500 errors confirm the single MongoDB instance was pushed into connection exhaustion.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/cr2vdpu34fu2dpepiv33.png&quot; alt=&quot;The illusion of scale. Adding a load balancer and a second application node successfully pushed our throughput to over 3,600 requests per second, processing 350,261 total requests.&quot;&gt;&lt;/p&gt;
&lt;p&gt;The illusion of scale. Adding a load balancer and a second application node successfully pushed our throughput to over 3,600 requests per second, processing 350,261 total requests.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/n1gfuo2yla5pnbw37fwn.png&quot; alt=&quot;The breaking point. Scaling the compute layer while leaving the data layer untouched resulted in a massive 26.5% failure rate, actively degrading the system’s availability.&quot;&gt;&lt;/p&gt;
&lt;p&gt;The breaking point. Scaling the compute layer while leaving the data layer untouched resulted in a massive 26.5% failure rate, actively degrading the system’s availability.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Total Requests Processed:&lt;/strong&gt; 350,261&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Throughput:&lt;/strong&gt; 3,638 requests/second&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Latency (p95):&lt;/strong&gt; ~492 ms (for successful requests)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Failure Rate:&lt;/strong&gt; &lt;strong&gt;26.57%&lt;/strong&gt; (93,069 failed requests, including explicit HTTP 500 database errors and massive timeouts spiking up to 36 seconds).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;discussion&quot;&gt;Discussion&lt;/h3&gt;
&lt;p&gt;The experimental data explicitly demonstrates the systemic risks of isolated horizontal scaling. While adding compute resources improved specific performance metrics, it fundamentally compromised system reliability, proving that scaling a single tier does not equate to scaling the system as a whole.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. The Shifting Bottleneck and Resource Exhaustion&lt;/strong&gt; The baseline load test (Architecture A) established a clear system ceiling: a single Node.js instance capped throughput at 2,193 requests per second with a p95 latency of ~796ms. Crucially, the failure rate was 0.00%. In this configuration, the compute layer acted as a strict upstream bottleneck. Because a single server could not process requests fast enough to overwhelm the database’s connection pool (configured to a maximum of 50 connections), the database was naturally shielded from resource exhaustion. The system was relatively slow under peak load, but highly stable.&lt;/p&gt;
&lt;p&gt;By introducing a load balancer and a second application node (Architecture 2A), the compute-layer bottleneck was removed. The system accepted more concurrent traffic, driving throughput up by roughly 65% (to 3,638 req/sec). However, because both nodes funneled this doubled traffic into the single, unscaled MongoDB instance, the bottleneck immediately shifted to the data layer. The sudden influx of concurrent requests exhausted the database connection pool, leading to connection lockups and explicit HTTP 500 errors.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. The False Positive of “Improved” Performance&lt;/strong&gt; A superficial reading of Architecture 2A’s metrics might suggest a performance win: the p95 latency for successful requests actually dropped by ~38% (from 796ms down to 492ms), and total processed requests nearly doubled.&lt;/p&gt;
&lt;p&gt;However, a rigorous analysis reveals this “scale” is an illusion. The data shows a catastrophic failure rate of 26.57% (93,069 failed requests). The reduction in p95 latency for successful requests is effectively a survivorship bias metric; it only accounts for the requests that managed to secure a database connection. Meanwhile, the maximum latency for blocked requests spiked to an unusable 36 seconds. The experiment proves that forcing high throughput at the compute layer without matching capacity at the data layer destroys system availability.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. The Inadequacy of Isolated Scaling&lt;/strong&gt; The results strictly invalidate the hypothesis that doubling compute resources linearly increases maximum throughput &lt;em&gt;safely&lt;/em&gt;. Instead, it proves that architectural scaling must be holistic.&lt;/p&gt;
&lt;p&gt;When Architecture 2A pushed the database beyond its concurrency limits, the system did not degrade gracefully — it fractured, dropping more than a quarter of all user requests.&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;The data validates a core principle of distributed systems:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;throughput and availability are often inversely correlated when shared resources are unscaled.&lt;/strong&gt; Architecture A was unscalable but highly available (100% success). Architecture 2A was highly performant for a subset of users but critically unavailable for the rest (26.57% failure).&lt;/p&gt;
&lt;p&gt;To successfully scale this system beyond the 2,193 req/sec ceiling of Architecture A without incurring the 26% failure penalty of Architecture 2A, the architecture must evolve to protect the database. Based strictly on these failure modes, future iterations must either synchronously scale the data layer (e.g., database sharding/replicas to handle the higher connection volume) or implement an asynchronous buffer (e.g., a message queue) to decouple the request ingestion rate from the database write rate.&lt;/p&gt;
&lt;h3 id=&quot;acknowledgements-amp-references&quot;&gt;Acknowledgements &amp;amp; References&lt;/h3&gt;
&lt;p&gt;The experiments and conclusions drawn in this article were directly informed by studying foundational distributed systems literature. Translating these concepts from theory into a live, containerized k6 experiment was made possible by the following resources:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Performance, Scalability &amp;amp; Throughput&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.youtube.com/watch?v=3HIV4MnLGCw&quot;&gt;&lt;strong&gt;MIT 6.004: Performance Measures (YouTube)&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;:&lt;/strong&gt; Provided the classic “laundry analogy” illustrating why pipelining improves overall throughput but not individual request latency.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.professorbeekums.com/performance-vs-scalability/&quot;&gt;&lt;strong&gt;Performance vs. Scalability (Prof. Beekums)&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;:&lt;/strong&gt; Clarified the boundaries between making a single server faster versus designing an architecture to handle increased load.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.allthingsdistributed.com/2006/03/a_word_on_scalability.html&quot;&gt;&lt;strong&gt;A Word on Scalability (Werner Vogels)&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;:&lt;/strong&gt; Foundational principles from Amazon’s CTO on building systems that scale gracefully.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cs.fyi/guide/latency-vs-throughput&quot;&gt;&lt;strong&gt;Latency vs Throughput (CS.fyi)&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;&amp;amp;&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://community.cadence.com/cadence_blogs_8/b/fv/posts/understanding-latency-vs-throughput&quot;&gt;&lt;strong&gt;Understanding Latency vs Throughput (Cadence)&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;:&lt;/strong&gt; Core definitions and metrics for measuring system speed and capacity.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The CAP Theorem &amp;amp; Consistency Patterns&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://robertgreiner.com/cap-theorem-revisited/&quot;&gt;&lt;strong&gt;CAP Theorem Revisited (Robert Greiner)&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;&amp;amp;&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;http://ksat.me/a-plain-english-introduction-to-cap-theorem&quot;&gt;&lt;strong&gt;A Plain English Introduction to CAP (ksat.me)&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;:&lt;/strong&gt; Explained the core mathematical trade-off: why our partitioned, load-balanced system had to sacrifice availability when tied to a single database.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cs.fyi/guide/consistency-patterns-week-strong-eventual&quot;&gt;&lt;strong&gt;Consistency Patterns (CS.fyi)&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;:&lt;/strong&gt; Detailed breakdowns of Weak, Eventual, and Strong Consistency, which directly inspired the Redis asynchronous queue experiment.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;High Availability, Failover &amp;amp; Database Replication&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.youtube.com/watch?v=LdvduBxZRLs&quot;&gt;&lt;strong&gt;Availability in Distributed Systems (GKCS — YouTube)&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;:&lt;/strong&gt; A practical war story detailing a real-world database migration, downtime management, and understanding the “9s of availability.”&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.slideshare.net/slideshow/scalability-availability-stability-patterns/4062682&quot;&gt;&lt;strong&gt;Scalability, Availability &amp;amp; Stability Patterns (SlideShare)&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;:&lt;/strong&gt; A comprehensive visual overview of architectural patterns.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.designgurus.io/blog/high-availability-system-design-basics&quot;&gt;&lt;strong&gt;High Availability System Design Basics (DesignGurus)&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;&amp;amp;&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/decoders_lord/system-design-availability-patterns-104i&quot;&gt;&lt;strong&gt;System Design Availability Patterns (Dev.to)&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;:&lt;/strong&gt; Practical guides on identifying and removing single points of failure.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.serverion.com/uncategorized/active-passive-vs-active-active-failover/&quot;&gt;&lt;strong&gt;Active-Passive vs Active-Active Failover (Serverion)&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;:&lt;/strong&gt; The blueprint used for configuring our Nginx load balancer to distribute compute traffic.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.enjoyalgorithms.com/blog/introduction-to-database-replication-system-design&quot;&gt;&lt;strong&gt;Introduction to Database Replication (EnjoyAlgorithms)&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;:&lt;/strong&gt; A deep dive into separating read/write workloads and scaling the data tier.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21448/i-doubled-my-servers-and-wrecked-my-availability</guid>
<pubDate>Sat, 27 Jun 2026 09:58:47 +0000</pubDate>
</item>
<item>
<title>How I Stopped a Race Condition from Creating Free Money in My Wallet System</title>
<link>https://coderlegion.com/20524/how-i-stopped-a-race-condition-from-creating-free-money-in-my-wallet-system</link>
<description>&lt;p&gt;&lt;strong&gt;The Bug That Doesn't Show Up in Testing.&lt;/strong&gt;&lt;br&gt;
When I set out to build a production-grade wallet system, I expected the hard parts to be the database schema, the transaction logic, or the authentication layer.&lt;/p&gt;
&lt;p&gt;What I didn't expect was that the most dangerous bug in the system would have nothing to do with wrong code - it would be about timing.&lt;/p&gt;
&lt;p&gt;This is the story of a race condition that lets users spend money they don't have, why it's invisible until it hits production, and how Redis turned it into a non-issue.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What a Wallet System Actually Does&lt;/strong&gt;&lt;br&gt;
Before we get to the bug, a quick picture of the system. A wallet holds a balance. Users can:&lt;/p&gt;
&lt;p&gt;Credit - add funds&lt;br&gt;
Debit - spend funds&lt;br&gt;
Transfer - move funds to another user&lt;br&gt;
Reverse - undo a transaction&lt;br&gt;
Every operation reads the current balance, validates it, and writes a new transaction record. Simple enough. The problem hides in that gap between reading and writing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Race Condition&lt;/strong&gt;&lt;br&gt;
Here's the scenario. A user has ₦5,000 in their wallet. They're on a slow connection, so they tap &quot;Pay&quot; twice. Two HTTP requests hit the server within milliseconds of each other. Both are trying to debit ₦3,000.&lt;/p&gt;
&lt;p&gt;Without any concurrency protection, this is what happens:&lt;/p&gt;
&lt;p&gt;Request A → reads balance: ₦5,000&lt;br&gt;
Request B → reads balance: ₦5,000&lt;/p&gt;
&lt;p&gt;Request A → ₦5,000 - ₦3,000 = ₦2,000 ✓ writes transaction&lt;br&gt;
Request B → ₦5,000 - ₦3,000 = ₦2,000 ✓ writes transaction&lt;br&gt;
Both requests read ₦5,000. Both pass the balance check. Both write a successful debit. The user just spent ₦6,000 from a ₦5,000 wallet - and the system called both transactions successful.&lt;/p&gt;
&lt;p&gt;This isn't a theoretical edge case. Under any real concurrent load, this happens. And the worst part: it never shows up in testing, because your test runner doesn't fire two real HTTP requests at the same millisecond.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why a Database Transaction Doesn't Save You&lt;/strong&gt;&lt;br&gt;
The instinct here is to reach for a database transaction. Wrap the whole thing in BEGIN / COMMIT and let Postgres sort it out.&lt;/p&gt;
&lt;p&gt;The problem is that the race happens before the database transaction begins. Both requests read the balance from the database (or worse, from a cache), both pass the validation check in application code, and then both open their database transactions. The damage is already done.&lt;/p&gt;
&lt;p&gt;You need a lock at the application layer, before the read even happens.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Fix: Distributed Locking with Redis&lt;/strong&gt;&lt;br&gt;
The solution is to ensure that no two operations on the same wallet can run at the same time. Before any balance operation begins, it must claim exclusive access. If another operation already holds the claim, it retries until the lock is free, or gives up after a timeout.&lt;/p&gt;
&lt;p&gt;This is a distributed lock, and Redis is the right tool for it. Here's the implementation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/utils/lock.utils.ts
import { randomUUID } from &quot;crypto&quot;;
import redisClient from &quot;../services/redis.service&quot;;

const LOCK_TTL_MS = 10_000;
const LOCK_RETRY_COUNT = 50;
const LOCK_RETRY_DELAY_MS = 100;

const RELEASE_SCRIPT = `
  if redis.call(&quot;GET&quot;, KEYS[1]) == ARGV[1] then
    return redis.call(&quot;DEL&quot;, KEYS[1])
  else
    return 0
  end
`;

const acquireLock = async (key: string): Promise&amp;lt;string | null&amp;gt; =&amp;gt; {
  const token = randomUUID();
  const result = await redisClient.set(key, token, &quot;PX&quot;, LOCK_TTL_MS, &quot;NX&quot;);
  return result === &quot;OK&quot; ? token : null;
};

export const withLock = async &amp;lt;T&amp;gt;(userId: string, fn: () =&amp;gt; Promise&amp;lt;T&amp;gt;): Promise&amp;lt;T&amp;gt; =&amp;gt; {
  const key = `wallet:lock:${userId}`;
  let token: string | null = null;

  for (let attempt = 0; attempt &amp;lt;= LOCK_RETRY_COUNT; attempt++) {
    token = await acquireLock(key);
    if (token) break;
    await new Promise((resolve) =&amp;gt; setTimeout(resolve, LOCK_RETRY_DELAY_MS));
  }

  if (!token) throw new Error(&quot;Could not acquire lock — resource is busy&quot;);

  try {
    return await fn();
  } finally {
    await redisClient.eval(RELEASE_SCRIPT, 1, key, token);
  }
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let's break down each decision.&lt;/p&gt;
&lt;p&gt;SET key token PX 10000 NX&lt;br&gt;
This is a single atomic Redis command that does three things at once:&lt;/p&gt;
&lt;p&gt;NX — only set the key if it does not already exist&lt;br&gt;
PX 10000 — expire the key after 10 seconds&lt;br&gt;
The value is a unique token (UUID), not just 1 or &quot;locked&quot;&lt;br&gt;
The atomicity of SET NX is the core guarantee. There is no gap between &quot;check if key exists&quot; and &quot;set the key&quot; — it's one operation. Two requests racing to acquire the same lock cannot both succeed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Retry logic&lt;/strong&gt;&lt;br&gt;
Rather than immediately rejecting a request when the lock is held, the implementation retries up to 50 times with a 100ms delay between each attempt - giving it up to 5 seconds to acquire the lock before giving up. This handles brief overlaps gracefully without dropping legitimate requests.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The TTL&lt;/strong&gt;&lt;br&gt;
The 10-second expiry is a safety net. If the application crashes mid-operation and fails to release the lock, it will expire automatically. Without this, one crash would lock that user's wallet forever.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Lua script on release&lt;/strong&gt;&lt;br&gt;
This is where most implementations get it wrong. A naive release looks like this:&lt;/p&gt;
&lt;p&gt;await redisClient.del(key); // ❌ dangerous&lt;br&gt;
Here's why that's broken: imagine a slow operation acquires the lock, but takes longer than 10 seconds. The TTL expires. A second operation acquires the lock. Now the first operation finishes and calls del - it just released the second operation's lock, not its own. The guarantee is broken.&lt;/p&gt;
&lt;p&gt;The Lua script fixes this by checking the token before deleting:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-lua&quot;&gt;if redis.call(&quot;GET&quot;, KEYS[1]) == ARGV[1] then
  return redis.call(&quot;DEL&quot;, KEYS[1])
else
  return 0
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because Lua scripts execute atomically in Redis, the check-and-delete is a single uninterruptible operation. You can only release a lock you actually own.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The withLock wrapper&lt;/strong&gt;&lt;br&gt;
Rather than exposing raw acquireLock / releaseLock functions and trusting every caller to use them correctly, the implementation wraps the entire pattern in a higher-order function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/services/wallet.service.ts
export const debitWallet = async (userId: string, amount: number): Promise&amp;lt;Transaction&amp;gt; =&amp;gt; {
  return await withLock(userId, async () =&amp;gt; {
    const wallet = await prisma.wallet.findUnique({ where: { userId } });

    if (!wallet || wallet.balance.lessThan(amount)) {
      throw new Error(&quot;Insufficient balance&quot;);
    }

    // create transaction, update balance...
    return transaction;
  });
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The finally block inside withLock ensures the lock is always released, even if the operation throws. A lock that leaks on error is as bad as no lock at all.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Preventing Deadlocks on Transfers&lt;/strong&gt;&lt;br&gt;
A transfer touches two wallets - sender and receiver. If two transfers run simultaneously in opposite directions (A→B and B→A), they can deadlock: each holds one lock and waits for the other indefinitely.&lt;/p&gt;
&lt;p&gt;The fix is to always acquire locks in the same order, regardless of who is sending to whom. We sort the user IDs before acquiring:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;export const withMultiLock = async &amp;lt;T&amp;gt;(userIds: string[], fn: () =&amp;gt; Promise&amp;lt;T&amp;gt;): Promise&amp;lt;T&amp;gt; =&amp;gt; {
  const sortedIds = [...new Set(userIds)].sort();
  const keys = sortedIds.map((id) =&amp;gt; `wallet:lock:${id}`);
  const tokens: string[] = [];

  try {
    for (const key of keys) {
      let token: string | null = null;
      for (let attempt = 0; attempt &amp;lt;= LOCK_RETRY_COUNT; attempt++) {
        token = await acquireLock(key);
        if (token) break;
        await new Promise((resolve) =&amp;gt; setTimeout(resolve, LOCK_RETRY_DELAY_MS));
      }
      if (!token) throw new Error(&quot;Could not acquire lock — resource is busy&quot;);
      tokens.push(token);
    }
    return await fn();
  } finally {
    await Promise.all(keys.map((key, i) =&amp;gt; releaseLock(key, tokens[i])));
  }
};

// In transfer service:
const result = await withMultiLock([senderId, receiver.id], async () =&amp;gt; {
  // both wallets locked — safe to proceed
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If A→B and B→A both arrive at the same time, both will sort to [A, B] and try to acquire A's lock first. One wins, the other waits. No deadlock.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Full System&lt;/strong&gt;&lt;br&gt;
The distributed lock is one layer in a larger stack. The complete wallet engine includes:&lt;/p&gt;
&lt;p&gt;Idempotency - Idempotency-Key header on credit and debit prevents duplicate transactions on network retries, backed by a 24-hour Redis TTL&lt;br&gt;
Webhook delivery with retry - BullMQ queue, 4 attempts, exponential backoff (2s base), per-attempt audit trail&lt;br&gt;
Balance caching - 60-second Redis TTL, invalidated on every write&lt;br&gt;
Token blacklisting - SHA-256 hashed JWTs stored in Redis with matching expiry, making logout actually secure&lt;br&gt;
Cursor-based pagination - transaction history that scales&lt;br&gt;
Full test suite - 103 tests, all mocked, no database required to run.&lt;br&gt;
Built with TypeScript, Express, Prisma, PostgreSQL, and Redis (ioredis). Deployed on Render with a GitHub Actions CI/CD pipeline.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why This Matters&lt;/strong&gt;&lt;br&gt;
Race conditions in financial systems are not exotic. They're the default outcome when you don't explicitly prevent them. Most wallet tutorials never mention it. Most wallet implementations in production have it.&lt;/p&gt;
&lt;p&gt;The fix isn't complicated - but you have to know to look for it.&lt;/p&gt;
&lt;p&gt;If you're building anything that touches money, check your balance reads. If there's no lock between the read and the write, the race condition is already there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Open for Collaboration&lt;/strong&gt;&lt;br&gt;
This project is open source and actively welcoming contributors. Whether you want to add a feature, improve test coverage, or spot something that could be done better - PRs are open.&lt;/p&gt;
&lt;p&gt;Check out the CONTRIBUTING.md for how to get set up and where to send your changes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/tochi27/Wallet_System.git&quot;&gt;https://github.com/tochi27/Wallet_System.git&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live API + Swagger:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://wallet-system-api-16cv.onrender.com/api-docs&quot;&gt;https://wallet-system-api-16cv.onrender.com/api-docs&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20524/how-i-stopped-a-race-condition-from-creating-free-money-in-my-wallet-system</guid>
<pubDate>Sat, 13 Jun 2026 14:56:16 +0000</pubDate>
</item>
<item>
<title>How I Found a Fake Job Assessment Repo Hiding Malware Inside SVG Files</title>
<link>https://coderlegion.com/20248/how-i-found-a-fake-job-assessment-repo-hiding-malware-inside-svg-files</link>
<description>&lt;p&gt;Like a lot of developers in this market, I’ve been taking freelance assessments and Discord job leads more seriously than I normally would.&lt;/p&gt;
&lt;p&gt;One of those assessments turned into a malware investigation.&lt;/p&gt;
&lt;p&gt;One day, I saw a post in a Discord server looking for a fullstack dev. I pitched. The reply looked routine at first: they DM’d me a requirements PDF for an assessment. I did not trust it, so I asked them to paste the requirements in the chat instead. They sent screenshots of the PDF, and it looked like a real assessment. Clean structure, clear expectations, nothing immediately screaming scam.&lt;/p&gt;
&lt;p&gt;Then they invited me to a GitHub repo called &lt;code&gt;E-commerce-template-12d46f3e&lt;/code&gt;. My first thought was that the name looked autogenerated, like they were appending random numbers for each assessment. That is when I started treating it like a security review, not a coding exercise.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lfzwfruzrb8cw2fcsyts.png&quot; alt=&quot;Github repo&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;what-i-checked-first&quot;&gt;What I checked first&lt;/h3&gt;
&lt;p&gt;The first thing I looked at was &lt;code&gt;package.json&lt;/code&gt;. I was expecting the usual red flags: weird postinstall hooks, obfuscated scripts, or packages I had never heard of. There was one outdated dependency, &lt;code&gt;@zeit/next-css&lt;/code&gt;, but nothing in &lt;code&gt;package.json&lt;/code&gt; looked obviously malicious.&lt;/p&gt;
&lt;p&gt;That is what made the repo interesting. The dependency list looked boring; the problem was in the application flow.&lt;/p&gt;
&lt;h3 id=&quot;the-suspicious-startup-path&quot;&gt;The suspicious startup path&lt;/h3&gt;
&lt;p&gt;Next, I looked at the startup scripts.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;dev&lt;/code&gt; script ran &lt;code&gt;server.js&lt;/code&gt;, and that file did something that immediately raised my guard: it called &lt;code&gt;startLoggingErrors()&lt;/code&gt; during server startup.&lt;/p&gt;
&lt;p&gt;At first glance, that looked like a harmless logging helper. But when I opened the related files, I found this chain:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;server.js&lt;/code&gt; starts the server.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lib/serverStartup.js&lt;/code&gt; calls &lt;code&gt;eval(...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lib/startupLogs.js&lt;/code&gt; reconstructs a hidden payload from files in &lt;code&gt;public/flags/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4nhalxuq12zfo0lo7a01.png&quot; alt=&quot;malicious files&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xvt2x5lej7m0c9cbpfh0.png&quot; alt=&quot;serverStartup eval function&quot;&gt;&lt;/p&gt;
&lt;p&gt;That &lt;code&gt;eval()&lt;/code&gt; was the key. A server startup path that reconstructs data from assets and then evaluates them is a hard stop.&lt;/p&gt;
&lt;h3 id=&quot;the-hidden-payload-in-the-svgs&quot;&gt;The hidden payload in the SVGs&lt;/h3&gt;
&lt;p&gt;The weirdest part was the assets.&lt;/p&gt;
&lt;p&gt;The repository had a bunch of country flag SVG files under &lt;code&gt;public/flags/&lt;/code&gt;. Those looked normal until I checked the HTML comments inside them. Each SVG had a comment that looked like a fragment of base64-encoded text.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sr5mtetekp0qx2c26nim.png&quot; alt=&quot;svg files&quot;&gt;&lt;/p&gt;
&lt;p&gt;The loader in &lt;code&gt;lib/startupLogs.js&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Walks through &lt;code&gt;public/flags/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Reads each &lt;code&gt;.svg&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Extracts the text inside &lt;code&gt;&amp;lt;!-- ... --&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Sorts and joins the fragments&lt;/li&gt;
&lt;li&gt;Base64-decodes the result&lt;/li&gt;
&lt;li&gt;Returns the decoded JavaScript&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lib/serverStartup.js&lt;/code&gt; feeds that decoded string into &lt;code&gt;eval()&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The two lines that made the whole thing click were basically this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const dir = path.join(process.cwd(), setLogUrl(&quot;sxeolf2iodjv&quot;));
eval(log_manager());
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ef4oeoczptskvnjbuqrq.png&quot; alt=&quot;logDetail function&quot;&gt;&lt;/p&gt;
&lt;p&gt;That is the bridge between the SVG comments and the executed payload.&lt;/p&gt;
&lt;p&gt;So yes, the SVG comments were not decorative. They were a distributed payload. The code was split across many innocent-looking image files so it would not stand out in a quick scan.&lt;/p&gt;
&lt;p&gt;I also used Codex as a coding agent to help with the defensive part of the review. Instead of running the suspicious code, I asked it to inspect the repo, trace the startup flow, and deobfuscate the payload safely so I could understand what it did without accidentally executing it. That helped confirm the hidden flow and surfaced additional suspicious paths for a wider audit.&lt;/p&gt;
&lt;p&gt;If you want the exact evidence trail, these are the key files:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;server.js&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lib/serverStartup.js&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lib/startupLogs.js&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;public/flags/*.svg&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-the-decoded-code-actually-does&quot;&gt;What the decoded code actually does&lt;/h3&gt;
&lt;p&gt;Once I reconstructed the payload without executing it, with Codex helping me safely decode and audit the hidden code, the intent was obvious.&lt;/p&gt;
&lt;p&gt;It is not just telemetry or error logging. It behaves like a stealer/dropper with persistence.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ixy64bkktwtvtcp3ni79.png&quot; alt=&quot;decoded&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Part of the reconstructed payload after safely decoding the SVG fragments. I redacted active infrastructure details before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;1-it-fingerprints-the-machine&quot;&gt;1. It fingerprints the machine&lt;/h4&gt;
&lt;p&gt;The payload gathers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;local IPv4 addresses&lt;/li&gt;
&lt;li&gt;public IP via &lt;code&gt;api.ipify.org&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;hostname&lt;/li&gt;
&lt;li&gt;OS type and version&lt;/li&gt;
&lt;li&gt;user info&lt;/li&gt;
&lt;li&gt;a machine/user identifier&lt;/li&gt;
&lt;li&gt;whether the machine looks virtualized&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That means it profiles the environment before doing anything else.&lt;/p&gt;
&lt;h4 id=&quot;2-it-sends-the-profile-to-a-remote-server&quot;&gt;2. It sends the profile to a remote server&lt;/h4&gt;
&lt;p&gt;It posts a JSON system profile to a remote endpoint over HTTP.&lt;/p&gt;
&lt;h4 id=&quot;3-it-drops-and-runs-additional-files-on-windows&quot;&gt;3. It drops and runs additional files on Windows&lt;/h4&gt;
&lt;p&gt;On Windows, it downloads executables into &lt;code&gt;AppData&lt;/code&gt; and runs them.&lt;/p&gt;
&lt;p&gt;It also writes a &lt;code&gt;runjs.vbs&lt;/code&gt; file into the Windows Startup folder so the code can persist across reboots.&lt;/p&gt;
&lt;p&gt;That is a classic persistence pattern.&lt;/p&gt;
&lt;h4 id=&quot;4-it-hunts-for-sensitive-files&quot;&gt;4. It hunts for sensitive files&lt;/h4&gt;
&lt;p&gt;The script recursively scans user paths and drives for files matching patterns like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.env&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.pem&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.cer&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.secret&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.xlsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;readme.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.ssh&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.aws&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.github&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is collection logic, not a developer convenience feature.&lt;/p&gt;
&lt;h4 id=&quot;5-it-targets-browser-data&quot;&gt;5. It targets browser data&lt;/h4&gt;
&lt;p&gt;It looks for browser profile directories for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Chrome&lt;/li&gt;
&lt;li&gt;Brave&lt;/li&gt;
&lt;li&gt;Edge&lt;/li&gt;
&lt;li&gt;LT Browser&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then it checks for files such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Login Data&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Web Data&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Local Extension Settings&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is the kind of place malware checks when it wants tokens, cookies, or saved credentials.&lt;/p&gt;
&lt;h4 id=&quot;6-it-targets-sticky-notes&quot;&gt;6. It targets Sticky Notes&lt;/h4&gt;
&lt;p&gt;It also checks the Microsoft Sticky Notes storage path on Windows. That is another common place where people accidentally leave sensitive information.&lt;/p&gt;
&lt;h3 id=&quot;indicators&quot;&gt;Indicators&lt;/h3&gt;
&lt;p&gt;I am intentionally redacting the live infrastructure in the public draft.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Redacted base URL: &lt;code&gt;[redacted-host]:[redacted-port]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Observed endpoints:&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/system-info&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/file-manage&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/download/track.js&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/download/apps/language_server_x64_x32_windows.exe&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/download/apps/assist_language_server_x64_x32_windows.exe&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Data sent to &lt;code&gt;/system-info&lt;/code&gt;:&lt;ul&gt;
&lt;li&gt;OS type, platform, release&lt;/li&gt;
&lt;li&gt;hostname&lt;/li&gt;
&lt;li&gt;user info&lt;/li&gt;
&lt;li&gt;local IP addresses&lt;/li&gt;
&lt;li&gt;public IP&lt;/li&gt;
&lt;li&gt;machine/user identifier&lt;/li&gt;
&lt;li&gt;VM detection flag&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Data uploaded to &lt;code&gt;/file-manage&lt;/code&gt;:&lt;ul&gt;
&lt;li&gt;file contents&lt;/li&gt;
&lt;li&gt;filename&lt;/li&gt;
&lt;li&gt;path&lt;/li&gt;
&lt;li&gt;system identifier&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;File types and stores targeted locally:&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.pem&lt;/code&gt;, &lt;code&gt;.key&lt;/code&gt;, &lt;code&gt;.cer&lt;/code&gt;, &lt;code&gt;.secret&lt;/code&gt;, &lt;code&gt;.xlsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;browser profile databases&lt;/li&gt;
&lt;li&gt;Microsoft Sticky Notes data&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;why-this-was-easy-to-miss&quot;&gt;Why this was easy to miss&lt;/h3&gt;
&lt;p&gt;The repo looked like a regular Next.js storefront at a glance.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;package.json&lt;/code&gt; was mostly boring. The app structure looked normal. The assets looked like flags. The malicious code was buried in a runtime path that almost nobody checks unless they are being cautious on purpose.&lt;/p&gt;
&lt;p&gt;That is the lesson here: malicious code does not have to live in &lt;code&gt;node_modules&lt;/code&gt;, and it does not have to look obviously hostile. Sometimes it hides in the things you think are static content.&lt;/p&gt;
&lt;h3 id=&quot;what-i-told-myself-while-reviewing-it&quot;&gt;What I told myself while reviewing it&lt;/h3&gt;
&lt;p&gt;I kept repeating one rule: if a codebase wants to run something dynamically at startup, I need to know exactly why.&lt;/p&gt;
&lt;p&gt;The moment I saw:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an obfuscated loader&lt;/li&gt;
&lt;li&gt;base64 fragments hidden across image assets&lt;/li&gt;
&lt;li&gt;&lt;code&gt;eval()&lt;/code&gt; on decoded content&lt;/li&gt;
&lt;li&gt;network calls to a hardcoded remote host&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I stopped treating it like a normal assessment and started treating it like an incident.&lt;/p&gt;
&lt;h3 id=&quot;what-i-would-advise-anyone-else-to-do&quot;&gt;What I would advise anyone else to do&lt;/h3&gt;
&lt;p&gt;If a Discord recruiter or random client sends you a repo:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Check &lt;code&gt;package.json&lt;/code&gt; first, but do not stop there.&lt;/li&gt;
&lt;li&gt;Inspect the runtime entrypoint, not just the UI code.&lt;/li&gt;
&lt;li&gt;Search for &lt;code&gt;eval&lt;/code&gt;, &lt;code&gt;new Function&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, &lt;code&gt;spawn&lt;/code&gt;, and startup hooks.&lt;/li&gt;
&lt;li&gt;Look inside static assets if the code mentions them.&lt;/li&gt;
&lt;li&gt;If the repo uses comments, weird strings, or base64-looking fragments, assume it may be encoded payload data until proven otherwise.&lt;/li&gt;
&lt;li&gt;Never run the project on your main machine before you understand the startup path.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;flow-diagram&quot;&gt;Flow Diagram&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;server.js
  ↓
lib/serverStartup.js
  ↓
lib/startupLogs.js
  ↓
public/flags/*.svg
  ↓
HTML comment fragments
  ↓
base64 reconstruction
  ↓
eval(payload)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;closing-thought&quot;&gt;Closing thought&lt;/h3&gt;
&lt;p&gt;I went in expecting a take-home assessment.&lt;/p&gt;
&lt;p&gt;What I found was a repo that used a clean-looking frontend as cover for a hidden payload loader. The lesson is simple: when something feels off, slow down and inspect the startup path. That is where malware likes to hide.&lt;/p&gt;
&lt;p&gt;If you are job hunting right now, be careful. A polished assessment can still be malicious.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Also,&lt;/strong&gt; currently open to &lt;strong&gt;Fullstack/Backend/AI roles&lt;/strong&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20248/how-i-found-a-fake-job-assessment-repo-hiding-malware-inside-svg-files</guid>
<pubDate>Thu, 11 Jun 2026 07:02:16 +0000</pubDate>
</item>
<item>
<title>Why Backend Secrets Leak More Often Than Developers Think: A Deep Dive into Runtime Security with XyPriss</title>
<link>https://coderlegion.com/19577/backend-secrets-more-often-than-developers-think-deep-dive-into-runtime-security-xypriss</link>
<description>&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kxfo3eekeb9peoc48ccy.png&quot; alt=&quot;Why Backend Secrets Leak More Often Than Developers Think: A Deep Dive into Runtime Security with XyPriss&quot;&gt;&lt;/p&gt;
&lt;p&gt;Modern backend applications rely heavily on environment variables for storing sensitive data, including database credentials, API keys, JWT secrets, internal service tokens, and cloud access keys.&lt;/p&gt;
&lt;p&gt;For years, the standard engineering approach across the ecosystem has been straightforward:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const apiKey = process.env.THIRD_PARTY_API_KEY;

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The underlying problem with this model is architectural: it assumes that every component within the runtime environment can be completely trusted.&lt;/p&gt;
&lt;p&gt;In a production-grade system, applications depend on a vast tree of third-party packages, logging systems, debugging tools, plugins, custom middleware chains, and external integrations. A single accidental log statement, an unhandled exception dump, or a compromised supply-chain dependency can expose the entire global &lt;code&gt;process.env&lt;/code&gt; object, leaking sensitive runtime data.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-core-problem-unrestricted-global-access&quot;&gt;The Core Problem: Unrestricted Global Access&lt;/h3&gt;
&lt;p&gt;Traditional backend runtimes treat security as an administrative layer added post-deployment. However, production environments are inherently hostile due to several factors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Supply Chain Vulnerabilities:&lt;/strong&gt; Dependencies change transitively, and malicious updates can target global objects.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Expanded Attack Surfaces:&lt;/strong&gt; As teams scale and applications grow, custom plugins and middleware increase the internal surface area.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lack of Isolation:&lt;/strong&gt; If a micro-library or an open-source logger has access to the global scope, it has access to your master encryption keys.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The larger an application becomes, the harder it is to guarantee that secrets remain isolated from the execution paths that do not strictly require them.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;architectural-hardening-the-xypriss-environment-shield&quot;&gt;Architectural Hardening: The XyPriss Environment Shield&lt;/h3&gt;
&lt;p&gt;XyPriss operates under a zero-trust runtime philosophy: &lt;strong&gt;Security must be embedded directly into the framework architecture itself.&lt;/strong&gt; To eliminate global scope leakage, XyPriss introduces the &lt;strong&gt;Environment Shield&lt;/strong&gt;. This mechanism restricts direct, unvalidated access to sensitive variables through the global &lt;code&gt;process.env&lt;/code&gt; and establishes strict, controlled access patterns.&lt;/p&gt;
&lt;h4 id=&quot;step-by-step-implementation&quot;&gt;Step-by-Step Implementation&lt;/h4&gt;
&lt;p&gt;Instead of allowing open reads, the XyPriss runtime isolates environment variables behind secure internal interfaces.&lt;/p&gt;
&lt;h5 id=&quot;1-insecure-pattern-blocked-by-runtime-&quot;&gt;1. Insecure Pattern (Blocked by Runtime)&lt;/h5&gt;
&lt;p&gt;Attempting to read raw environment variables globally will fail or be intercepted to prevent accidental exfiltration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// ❌ This pattern is restricted or discouraged to prevent supply-chain leakage
const databasePassword = process.env.DATABASE_PASSWORD;

&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;2-secure-access-pattern&quot;&gt;2. Secure Access Pattern&lt;/h5&gt;
&lt;p&gt;To securely retrieve configuration data, applications must use the built-in system environment interface. This guarantees that access is explicit, auditable, and isolated from arbitrary third-party code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// ✅ Secure, controlled access via the internal system layer
const databasePassword = __sys__.__env__.get(&quot;DATABASE_PASSWORD&quot;);

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By forcing retrieval through the &lt;code&gt;__sys__.__env__&lt;/code&gt; gateway, the framework reduces the risks of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Accidental Secret Leakage:&lt;/strong&gt; Prevention of raw memory and object dumps from exposing strings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unsafe Logging:&lt;/strong&gt; Log utilities cannot accidentally parse or intercept variables through standard object serialization.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Insecure Third-Party Access:&lt;/strong&gt; Isolated execution paths ensure that untrusted modules cannot poll the environment state.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;moving-beyond-environment-variables-the-broader-ecosystem&quot;&gt;Moving Beyond Environment Variables: The Broader Ecosystem&lt;/h3&gt;
&lt;p&gt;The environment configuration architecture is just one aspect of the XyPriss runtime design. The framework applies this zero-trust methodology across all low-level system operations:&lt;/p&gt;
&lt;h4 id=&quot;1-hardened-filesystem-management&quot;&gt;1. Hardened Filesystem Management&lt;/h4&gt;
&lt;p&gt;Similar to the Environment Shield, filesystem interactions are abstracted through a dedicated system gateway (&lt;code&gt;__sys__.fs&lt;/code&gt;). This prevents directory traversal attacks and restricts arbitrary file read/write operations from unauthorized dependencies.&lt;/p&gt;
&lt;h4 id=&quot;2-native-security-layers&quot;&gt;2. Native Security Layers&lt;/h4&gt;
&lt;p&gt;XyPriss relies on highly optimized native internals designed for rapid execution and low-level memory safety. Security protocols and baseline safety configurations are baked into the core compilation, avoiding the performance degradation typically associated with runtime validation layers.&lt;/p&gt;
&lt;h4 id=&quot;3-zero-trust-module-and-plugin-isolation&quot;&gt;3. Zero-Trust Module and Plugin Isolation&lt;/h4&gt;
&lt;p&gt;Plugins and extensions do not inherit root-level privileges by default. Every module must explicitly interface with the native engine hooks, ensuring that any malicious or poorly written dependency remains contained within its specific execution context.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion-1&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;The objective behind XyPriss was never to assemble another iterative framework wrapper. The objective was to fundamentally rethink how backend infrastructure behaves in high-throughput production environments. After two years of engineering, the &lt;strong&gt;v9.10.16++&lt;/strong&gt; stable branch delivers a production-ready ecosystem built for native performance and absolute runtime reliability.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;links-and-resources&quot;&gt;Links and Resources&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Environment Shield Documentation:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://xypriss.nehonix.com/docs/security/environment-shield&quot;&gt;xypriss.nehonix.com/docs/security/environment-shield&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Environment System Reference:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://xypriss.nehonix.com/docs/system/environment&quot;&gt;xypriss.nehonix.com/docs/system/environment&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Filesystem Architecture:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://xypriss.nehonix.com/docs/system/filesystem&quot;&gt;xypriss.nehonix.com/docs/system/filesystem&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Nehonix-Team/XyPriss&quot;&gt;Nehonix-Team/XyPriss&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;If you support open-source infrastructure and secure-by-default engineering, please take a moment to star our repository on GitHub. Your support drives the continuous maintenance and development of the ecosystem.&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19577/backend-secrets-more-often-than-developers-think-deep-dive-into-runtime-security-xypriss</guid>
<pubDate>Wed, 03 Jun 2026 12:04:05 +0000</pubDate>
</item>
<item>
<title>What is? — A Backend Developer Learning in Public</title>
<link>https://coderlegion.com/19549/what-is-a-backend-developer-learning-in-public</link>
<description>&lt;h3 id=&quot;hello-everyone&quot;&gt;Hello Everyone&lt;/h3&gt;
&lt;p&gt;If this title caught your attention, feel free to hit the ❤️ button — it really helps!&lt;/p&gt;
&lt;p&gt;Welcome to the &lt;strong&gt;&quot;What is?&quot;&lt;/strong&gt; series.&lt;/p&gt;
&lt;p&gt;In this series, I’ll share everything new I discover during my programming journey as a learner, just like many of you. The goal is simple: learn together and explain concepts in the easiest way possible.&lt;/p&gt;
&lt;p&gt;This series will mainly focus on &lt;strong&gt;Backend Development&lt;/strong&gt; because that’s the field I’m currently learning and working in .&lt;/p&gt;
&lt;p&gt;Most of the posts will be about:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Golang&lt;/li&gt;
&lt;li&gt;Backend fundamentals&lt;/li&gt;
&lt;li&gt;APIs&lt;/li&gt;
&lt;li&gt;Databases&lt;/li&gt;
&lt;li&gt;Authentication&lt;/li&gt;
&lt;li&gt;WebSockets&lt;/li&gt;
&lt;li&gt;Testing&lt;/li&gt;
&lt;li&gt;And other backend-related topics&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I believe these posts can help developers who are just getting started.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-am-i-writing-this-series-&quot;&gt;Why Am I Writing This Series?&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;“If you can't explain it simply, you don't understand it well enough.”&lt;br&gt;
— Often attributed to Albert Einstein&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;One of the best ways to truly understand something is to explain it to someone else.&lt;/p&gt;
&lt;p&gt;Writing these posts pushes me to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Understand topics more deeply&lt;/li&gt;
&lt;li&gt;Simplify complicated concepts&lt;/li&gt;
&lt;li&gt;Practice communication skills&lt;/li&gt;
&lt;li&gt;Document my learning journey&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sometimes documentation feels overwhelming, and beginner tutorials skip important details. I want this series to be beginner-friendly and focused on real understanding instead of just copying code.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-you-can-expect&quot;&gt;What You Can Expect&lt;/h3&gt;
&lt;p&gt;The goal is to create posts that are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Simple and direct&lt;/li&gt;
&lt;li&gt;Beginner-friendly&lt;/li&gt;
&lt;li&gt;Focused on understanding concepts&lt;/li&gt;
&lt;li&gt;Based on real learning experiences&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I’ll try to explain things the way I wish they had been explained to me when I started learning backend development.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;follow-my-journey&quot;&gt;Follow My Journey&lt;/h3&gt;
&lt;p&gt;If you like the idea, feel free to connect with me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mohamed8eo&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/mohamed-elmorsy-dev/&quot;&gt;LinkedIn&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is my first post on DEV Community, and I’m excited to start this journey with you.&lt;/p&gt;
&lt;p&gt;See you in the first &lt;strong&gt;&quot;What is?&quot;&lt;/strong&gt; post �&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19549/what-is-a-backend-developer-learning-in-public</guid>
<pubDate>Wed, 03 Jun 2026 09:29:23 +0000</pubDate>
</item>
</channel>
</rss>