Skip to content
EK

Search

Mapping a legacy Prisma schema without reliable telemetry

More than 150 models, legacy pages still in production, and traffic data I could not trust. How I worked out which models a limited set of functions actually depends on, where the script needed a human, and what the result does not prove.

05 Jul 2026 · 10 min read

  • 150+ models mapped
  • 0 tables deleted

The dashboard I work on has a Prisma schema with more than 150 models in it. Some of them belong to the current product and some to the versions before it, because pages from those older versions are still in the repository and still in production, and their tables still get written to. The schema remembers everything the product ever was.

This summer we started building a new backend on NestJS to replace the Next.js API routes, and for that I needed a clean subset of the schema: the models that a limited set of functions actually depends on, in a form that validates and migrates on its own, with a written reason next to every model for why it is in or out. It was not a cleanup, because nothing was going to be deleted, and what I needed was a map of the schema with evidence attached to every model.

The question sounds simple: which of these models are in use? It turned out this is not one question, and the obvious ways of answering it were wrong for this codebase.

Why “is it referenced anywhere” does not answer it

The first thing anyone does is grep for prisma.<model> and count. I did not trust that, for a reason that is obvious once you say it out loud: dead code references dead code. A legacy page that nobody opens still imports its legacy models, honestly and correctly. A reference count says “used” for everything that is merely present.

It also misses things in the other direction, because some models never show up as a direct call at all and are only ever read through a nested include inside another model’s query, and some tables are not features in the first place: the bookkeeping table of a migration runner has no page and no route, and it is very much alive.

So “referenced anywhere” was not going to work, and what I actually wanted was “reachable from something we intend to keep”, which is a different question, because it needs a set of roots to start from.

The signal I could not use

Normally you would get the roots from traffic, because whatever gets requested is live by definition, but here that fell apart in two ways at once. The analytics on the dashboard kept about a day of history, and the legacy application was still live, so whatever traffic I did see was mostly legacy usage, which is the opposite of what I wanted to measure.

I tried git recency as a stand-in, on the idea that a page nobody touched in a year is probably legacy, and it turned out to be useless, because a repo-wide reformat in the spring had touched every file and every orphan page looked freshly edited.

Two anchors that need no logs

Two things turned out to be trustworthy without any telemetry.

The first is the mobile client. A mobile app can only call the routes written into it. I pulled the URL strings out of the client code with ts-morph, resolved the templates, and matched them against the dashboard’s route handlers. That gives a closed set: these handlers are reachable from the app, and nothing outside the set is. One caveat I want to keep honest, because it is easy to overstate: I analyzed the current client code, and people’s phones do not all run the current version, because some users never update, and a route that an older build still calls may already be gone from the code I looked at. So this is “the routes the current client version can call”, not “every route any installed version can still call”, which is close, and good enough for my purpose, but not the same claim.

The second is the database itself, a count(*) and the last write time for every table, because the code only tells you what could run, and the database tells you what actually did.

From routes to models

With the roots in hand, the chain looks like this.

Roots are the mobile-reachable handlers, plus the dashboard pages a user can actually navigate to (parsed from the sidebar and route registry, then expanded through page links), plus auth and public pages and middleware.

From the roots I walked the dependency graph at file level. graphify gave me the import graph, and I added two more edge types on top, because imports alone are not how a Next.js app hangs together: URL string literals in fetch calls, and links between pages, and everything reachable from a root counts as in.

The next step is the bridge from code to schema: with ts-morph I found every prisma.<model>.<op>(...) call site, resolving the receiver through the type checker so that destructured and extended clients count too, which gave me about a thousand call sites mapped from file to model. Every model touched by a reachable file becomes a seed.

After that comes the closure: a seed model with a required relation to another model pulls that model in, because otherwise the subset has a dangling foreign key and will not migrate, and this repeats until nothing new is added. The rule that matters most here is that optional relations are not followed automatically, because if you follow nullable links, one ? field drags the entire legacy graph back in. Those relations get recorded as “referenced, optional”, and a human decides about each of them.

live = seed
repeat until nothing new is added:
  for model in live:
    for relation in model.relations:
      if relation.required and relation.target not in live:
        live.add(relation.target)

The last step is the data check, which runs for everything that did not end up in the live set.

The relation graph came from Prisma, not from a regex over the schema file. That matters because implicit many-to-many join tables and composite keys are exactly the places a regex gets wrong.

There is no turnkey tool for this, because knip and similar tools stop at the file and export level and nothing maps files to schema models, so it is a compose-your-own pipeline, and the composing is most of the work.

The tradeoff I made on purpose

I went with file-level reachability instead of a real call graph, because a live route almost never imports a whole dead module without using it, and file-level is an order of magnitude cheaper to compute and, more importantly, to audit by hand.

The cost showed up right away, because a couple of large service files import half the legacy models, so anything that reaches those files “reaches” everything they touch. I did not try to fix that in the graph, and instead I put a list of domains we had already decided were in or out on top of the reachability result as an override. The output records which models were kept by evidence and which by decision, because those are two different kinds of “in” and the evidence column has to say which.

Where the script needed a human

There were four places where the script needed me to step in.

Models that are only accessed through include: or nested writes never appear as a call site, so the touchpoint scan is blind to them. A handful of models were added by hand after reading the code that uses them.

Dynamic access like prisma[name] is invisible to static analysis, and it is a bad pattern to begin with, because nobody reading the code can tell which table it touches either, but it was there in the codebase in a couple of places, which grep found and I resolved by hand.

A few models had conflicting signals: no static reachability from a live root, a legacy UI that clearly used them, and zero rows in the database. Those I decided one at a time.

And then there is the one I got wrong myself. The first run of the data check said the legacy tables were empty and the new ones were in use, which was exactly what I wanted to hear, and it was wrong, because the connection string in my local environment pointed at staging. On production it was the other way around: the legacy tables held hundreds to thousands of rows, and the newer ones were nearly empty, because those features were not rolled out yet at the time. So the static analysis had been right all along, and the data check had simply been asked the wrong database.

What came out

About two thirds of the models went into the subset. The rest split into: live but outside the chosen functions (still in production, still written to, just not part of the subset), excluded by a product decision, cut by namespace, one infrastructure table, and the few decided by hand. The subset validates and migrates from an empty database on its own.

Two findings came for free along the way: roughly a third of the route handlers in the repository are unreachable from any live root, so the number of handlers in a repo is not the size of the live API, and a group of dashboard pages is unreachable from navigation, which is a decent list of what to look at next, later and separately.

Nothing was deleted by any of this, because deletion is a different step with its own risk, and the evidence it would need is now written down per model.

What this analysis does not prove

I want to be precise about the limits, because a clean-looking classification invites more trust than it has earned.

“Unreachable from the chosen roots” is not “never called”, because cron jobs, seed scripts and admin scripts run from their own entry points, and while I enumerated the ones I knew about separately, I could have missed one.

“Live but legacy” is a scope decision, not an observation that the tables are empty, because production still writes to them.

The root set is the current client version, and some users are still on older versions that were never updated, so a route that an older build still calls, and that newer versions have already removed from the code, would not be in my list.

The data check is only as good as the connection string, as I found out.

And the override list means part of the classification is a decision rather than a measurement. Keeping those apart in the evidence column is the whole point of having an evidence column.

That is the whole story, and if you have done the same kind of schema archaeology with better tools than knip and a pile of ts-morph scripts, I would honestly like to hear about it, because I would rather not compose this pipeline a second time. The easiest place to find me is LinkedIn.

Read next

Want to talk about a role or a system like these?