Choosing Mongoose over Prisma mid-project, and what it cost
The migration was not about query syntax. It was about what Prisma had been enforcing for free, and about writing each of those guarantees back by hand.

Market4 moved from Prisma and Postgres to Mongoose and MongoDB on 3 August 2026, part-way through building the product. The query syntax was the small part. What the migration actually cost was four guarantees Prisma had been providing without being asked: generated enum types, foreign keys, a retention model expressed in application code, and constructor wiring that a generated client had been keeping honest. Each of those had to be rebuilt by hand, and one of them broke silently before anybody noticed.
Prisma generates a TypeScript enum for every enum block in the schema, so the database's accepted values and the type used at call sites are the same declaration. Mongoose has no such generator. The replacement in this codebase is one file where each vocabulary is declared once as a readonly tuple and then projected into two shapes: a string-literal union type for annotations, and a frozen lookup object so call sites keep writing OrgRole.OWNER as they did before. The tuple itself is what the Mongoose schema passes to its enum option, which is what makes the database validator and the TypeScript type unable to drift apart.
| What Prisma gave | What replaced it | What it cost |
|---|---|---|
| Generated enum per schema enum | One tuple, projected into a type and a lookup | A file to maintain, and a rule that the tuple is the source |
| Foreign keys enforced by the database | Explicit lookups and a defined fallback | One webhook path had to be redesigned |
| Retention expressed in application code | TTL indexes carrying the window | A narrowed window now deletes in one irreversible pass |
| A generated, named client | Hand-wired service constructors | An argument-order bug that disabled scheduled publishing |
Foreign keys had been doing work nobody had written down. A billing webhook arrives carrying an organisation identifier in its custom data; under Postgres, an identifier pointing at an organisation that does not exist raised a constraint violation, the error escaped, and the endpoint answered 500. MongoDB has no foreign keys, so the same bad identifier now simply finds nothing. The code resolves the organisation by reading its document, falls back to the payment provider's own link when that fails, and answers with a processed flag of false when neither resolves.
The behaviour change was deliberate and better. A payment provider retries a 500 indefinitely, so the old path turned one unresolvable event into a permanent retry loop. Answering "not processed, and here is the record of it" ends the loop and leaves the evidence in place. The point worth keeping is that nobody set out to redesign this: the constraint was removed by the database choice, and the redesign was the bill for it.

Every transactional path failed against a perfectly healthy local MongoDB. The database was fine. It just was not a replica set, and transactions do not work without one.

Market4 turns one release note into a changelog page, a blog post, a mail-out and a week of social posts — and then tells you which of them brought anyone back.
Retention windows in this codebase now come from TTL indexes declared alongside each model, and synchronising indexes updates an existing index's expiry in place. That is convenient and it hides a hazard: narrowing a window does not phase anything out. If a window goes from 400 days to 90, the first TTL pass after the new index is live deletes every document between those bounds, at once and irreversibly. Five collections carry such a window here, so the operational rule is to count what a new window would cover before changing it, and to archive first if the number is unacceptable.
The conversion was done by seven agents working on separate service clusters in parallel, with a coordinator joining the pieces afterwards. The joining is where the interesting failure lived. One service's constructor had gained an argument during the conversion, and the call site still passed the old sequence, which put a webhook service into the slot meant for a publishing service. Two consequences followed from one misplaced argument: every webhook the monitor tried to send was dropped, because the field holding the webhook service was undefined; and no scheduled social post would ever have been published, because the monitor is what pushes posts and it was holding the wrong object.
Positional arguments are the mechanism here, and a generated client is what had been hiding the risk. This class of error is exactly what a type checker exists for, and it was reported as a TS2345 argument-type error rather than discovered in production. The lesson taken from it was not about MongoDB at all: run the type check across the whole workspace after a parallel refactor, before running anything else.
No. Rewriting queries was mechanical work with a clear finish line. The costly part was replacing what the old stack enforced implicitly: generated enum types, foreign-key constraints, and a client whose named shape kept call sites honest. Each of those is invisible while it works, and each produced a specific piece of new code or a new operational rule once it was gone.
Code that relied on the violation as a signal. In this codebase a billing webhook carrying an identifier for a missing organisation used to raise a constraint error and return 500; without foreign keys it finds nothing and needs an explicit fallback. The redesign was an improvement, because a payment provider retries a 500 forever, but it was work created by the migration rather than chosen.
They are safe to add and hazardous to narrow. Synchronising indexes updates the expiry of an existing index in place, and the next TTL pass deletes everything now outside the window in one go, with no phasing and no undo. Before shortening a window, count the documents the new bound would cover and archive them if that number matters. Widening a window is harmless by comparison.
Type-check the whole workspace before running anything. In this migration seven service clusters were converted in parallel and the failure was at a join: a constructor gained an argument, one call site kept the old order, and a webhook service ended up in a publishing service's slot. The compiler reported it as an argument-type error. Tests would have found it later, and production later still.
Publishing a post sends two independent announcements. One IndexNow request reaches Bing, Yandex, Seznam, Naver and Yep. Google takes no part in IndexNow, so it is told by re-submitting the sitemap.

We split one long numbered prompt into two. Every step after the cut changed number, and the sentences that navigate by those numbers went on pointing at whatever now wore them.