Engineering

Publishing on Replit: five ways a deploy surprised us

A schema diff that runs first, and a database driver that landed in the browser bundle.

Kirtiman Sharma5 November 20264 min read
  • Deployment
  • Lessons
Publishing on Replit: five ways a deploy surprised us. Engineering on the YuktiBOS articles pages.

YuktiBOS is written and reviewed in Claude Code, kept on GitHub, and published through Replit. The rule: Replit's workspace stays a copy of GitHub's main branch, and Replit Agent is used for publishing only. A publish takes about ten minutes. These are the times the publish step, not the code, was what broke.

1. Replit changes your database before your migrations run

When we published on 4 November, Replit applied its own schema diff first. It saw new columns for spend caps, renamed the old ones to names ending _legacy, and moved no data. It asked me to confirm the rename. I answered "rename", which keeps the data.

Our migration runner came second. Our migration 0282 checked, saw the new column already existed, and skipped. The result would have been every spend cap on production set to NULL. In our system NULL means uncapped.

The fix was migration 0287 (ADR-477): fill each new cap column from its legacy column, at the rupee-dollar rate the code used (83), wherever the new value is empty and the old one is not. It adds the monthly envelope row if missing. It is safe to run twice and it drops nothing. If you build on Replit, assume its diff runs before yours.

(NULL is a sore word for us. See the post on the cap that became Rs 10,00,000.)

2. The browser got a database driver

Production build failed with "Can't resolve 'perf_hooks'" inside the postgres package. A story-card component marked "use client" imported one small helper from a file that, further down, imports the database through a dynamic import. The build bundler pulled the Postgres driver into the browser bundle.

The fix (ADR-478): move the pure helper into its own file with no database import. The rule we wrote down: a client component never imports a module that reaches the database, even through a dynamic import.

3. Replit builds indexes before you can

Replit creates indexes from the development schema before our SQL runs in production. So never add a UNIQUE index on an existing table whose production data might break it. If you must: drop it on dev, publish, let the boot runner create it, then recreate on dev. And never use casts or COALESCE in index expressions, because Replit mangles them. Use partial indexes.

4. The firewall said no to new packages

When we added the Tiptap editor, Replit's package firewall returned 403 on install. The fix (ADR-504) pinned Tiptap to an exact version released in October, and pinned five of its new dependencies to releases that were at least three weeks old, because the newest were younger than the firewall allows. The lockfile stayed identical to the last good deploy apart from the Tiptap additions. The note says to lift the pins once the firewall allows newer releases.

5. A Date inside a template string

On every minute tick, production logged "auto-posting failed" with a message about a string argument. A date was passed straight into a SQL template string. The ORM has no column type to encode a date that only sits inside a template, so it reached the database driver as a raw Date object, which the driver rejects. The weekly trend scan had the same pattern. ADR-500 added a small helper that sends an ISO string cast to a timestamp, and a scan of every SQL template found no other case.

Earlier, a similar bug: the daily social job's backlog count compared task status with "done", which is not a status. Postgres refused it, and that client's posts failed every night.

Smaller lessons from the handoff note

  • Two known build failures: a default import from an ESM-only package, and a non-hook function named use....
  • Before approving a publish, open "Show details" and confirm there is no DROP TABLE, DROP COLUMN, TRUNCATE or DELETE.
  • I publish myself. The browser tool refused to type into the Replit Shell. That is a safety feature and I would not want it disabled.
  • Always fetch GitHub first. Trust GitHub over any handoff note, including my own.

What I changed because of this

Migrations are idempotent. Every statement uses IF NOT EXISTS. A migration does not assume that Replit has or has not already made the change. No new unique index is added to an existing table. A file is never edited after it has run anywhere.

What's still open

Each of these was found after something went wrong in the publish, not before. I do not have a pre-publish checklist that catches class 1 automatically. Today it depends on the migration being written defensively.

Numbers at a glance

ItemValue
Publish timeabout 10 minutes
Spend caps that would have been NULL3 columns (agent caps, kind ceilings, provider keys)
Rate used to backfill83 rupees to the dollar
Package age rule from the firewallat least 3 weeks
ADRs477, 478, 500, 504
Want to try it, or tell us what to build?

We are onboarding a small number of businesses for early access, and we read every feature request.

More in engineering

See it on your own business.

We are onboarding a small number of businesses for early access. Tell us what you do and what you would like off your plate.