The boring stack, on purpose

Render and Turso replaced by Railway and PostgreSQL in a single pull request, deleting the hand-written migrator Turso had forced into existence, plus a CI workflow retired in favor of agents bumping their own version.

The hosting and database stack changed underneath the whole game today, in one pull request: Render and Turso out, Railway and a managed PostgreSQL instance in. No data carried over, since there was little worth carrying and a fresh schema was simpler than a migration script for one, so the swap was really a rewrite of every place the app talked to its database, done once and merged whole rather than staged behind a flag.

Deleting the workaround instead of patching it again#

The database driver itself had already needed one rescue: Turso’s SQLite dialect had rejected Drizzle’s own migration-tracking table, forcing a hand-written migrator into existence just to apply schema changes at all. Today that hand-written migrator came out entirely, replaced by Drizzle’s own stock PostgreSQL migrator, because PostgreSQL never needed a workaround for its own tracking table in the first place. Thirteen incremental SQLite migration files collapsed into a single clean PostgreSQL one at the same time, since a fresh start had no history left to preserve. The decision underneath the whole thing was not really about Railway or Render, it was about how much longer an edge-case database was worth patching around before a mainstream one became the cheaper option.

A client library that isn’t an object#

The database client wraps every query in a timer, and the wrapper had to change shape along with the driver: the old client exposed named methods (execute, batch) that a Proxy could intercept one at a time, while the new one is itself a callable, invoked directly as a tagged template rather than through any named method. Intercepting individual method names stopped making sense once there were no methods to name, so the proxy trap moved from get to apply, timing the call itself instead of whichever method happened to be invoked on it:

// before: intercept specific named methods on an object
get(target, prop) {
  if (prop === 'execute' || prop === 'batch') {
    return async (...args) => { /* time it */ };
  }
  return value.bind(target);
}

// after: the client itself is callable, so time the call directly
apply(target, thisArg, args) {
  const start = performance.now();
  const result = Reflect.apply(target, thisArg, args);
  Promise.resolve(result).finally(() => recordDbQuery(performance.now() - start));
  return result;
}

Versioning without a bot pushing to main#

A smaller change closed out the day. The workflow that bumped the app’s version on every merge needed github-actions[bot] to push directly to the protected main branch, which branch protection exists specifically to prevent, so the workflow and its supporting scripts came out entirely. In their place, agents now bump the version themselves, once per branch, as part of the same work that needed a version bump in the first place, guarded by a check against the branch’s own diff so the same bump can’t land twice.

It’s simpler and more reliable to have agents bump the version themselves, exactly once, alongside the rest of the work.

Where this leaves things#

One pull request swapped the ground the whole app stands on, and a second stopped asking a bot to do something branch protection was already built to stop it from doing. Neither changed what a player sees; both changed what the next few weeks of shipping features on top of this stack will cost.