scrml.dev v0.7.1
Articles › The why-not

The ORM trap

authored by claude, rubber stamped by Bryan MacLee

TL;DR: ORMs trade raw SQL for a query DSL that approximates SQL plus a schema file that drifts from the database. When the compiler owns the SQL block and the schema is in the same AST, neither tradeoff exists.

Every ORM I have ever looked at started by promising it would let me write code instead of SQL, and ended with me reading the generated SQL anyway because something was off.

I am not an experienced framework developer. I can hobble through React if I HAVE TO. But across about twenty compiler attempts, the same shape kept showing up: if the language can read the database schema at compile time, the entire reason ORMs exist quietly evaporates. The query string stops being a string. The schema stops being a separate file. The migration story stops being a separate workflow.

This is the third feature from the browser-language overview piece. Boundary disappearing was the second. Query layer collapsing into the compiler is the third. Same structural argument: vertical integration of a thoughtful design unlocks features that piecewise alternatives literally cannot ship.

Why ORMs exist in the first place

Raw SQL strings in JavaScript are noisy. You write a string. The string has placeholders. The placeholders bind to runtime values. The result has a shape your IDE cannot infer because the shape lives in a database, not in the type system. So someone built an ORM. Then someone built a typed query builder. Then someone built a schema-first ORM. Each one was a different bet on how to shrink the gap between "I have data in a database" and "I have a typed value in my program."

The bet looks like this:

1. Declare the schema in a separate file (Prisma's schema.prisma, Drizzle's schema.ts, TypeORM's entity classes). 2. Run a generator that produces a typed client from that schema. 3. Use the client's query DSL to express queries in something that reads like JavaScript. 4. The DSL compiles down to SQL at runtime.

The first three steps each look like an ergonomic win. The fourth is where the receipt comes due.

What the seam actually costs

Pick any mature stack. Prisma plus a Postgres database. Drizzle plus SQLite. Kysely plus MySQL. The shape of the costs is the same.

The schema is a separate artifact. schema.prisma is not the database. It is a description of what you would like the database to look like. The database has its own state. The two are kept in sync by a migration tool that runs out-of-band from your build. If they ever disagree, your generated types are confidently wrong.

The generated client is a separate artifact. You run prisma generate after every schema change. The client is large. The client's types are the only thing your editor knows about. If the generator did not run after your last schema edit, your editor is showing you stale autocomplete and the build silently uses the stale client.

The query DSL approximates SQL but is not SQL. Drizzle gets impressively close. So does Kysely. Both still have edge cases where the right SQL exists but the DSL does not express it cleanly, so you reach for sql.raw() or escape hatches and the type safety of the surrounding query stops applying. You are now writing a SQL string with no schema awareness, inside a tool that exists because raw SQL strings have no schema awareness.

Column typos are a runtime question. If you write where: { usrname: "alice" } against a table whose column is username, the typed client may catch it, depending on how the schema generator named your fields. If you write the same typo inside a sql.raw() block, nothing catches it until the query runs against the live database. The diagnostic surfaces in production logs at 3am, not at compile time.

Migrations are their own subsystem. Different tool. Different files. Different vocabulary (migrate dev, migrate deploy, db push, prisma migrate resolve). Their job is to walk the live database from "what it looks like now" to "what schema.prisma says it should look like." When that walk fails, you are several steps removed from the code that triggered it.

This is the seam. It is the same shape as the server-boundary seam. Multiple tools, each good at one piece, none of them owning the whole pipeline. The ORM exists to plug one specific gap in that pipeline. It does an honest job. It does not eliminate the pipeline.

The same feature in scrml

<schema>
    users {
        id:       integer primary key
        username: text not null unique
        email:    text not null
    }
    posts {
        id:        integer primary key
        author_id: integer not null references users(id)
        title:     text not null
        body:      text not null
    }
</>

<program>

<db src="./app.db" tables="users, posts"/>

${
    function getUserPosts(userId) {
        return ?{`
            SELECT p.title, p.body, u.username
            FROM posts p
            JOIN users u ON u.id = p.author_id
            WHERE p.author_id = ${userId}
            ORDER BY p.id DESC
        `}.all()
    }
}

</program>

That's the entire feature. One file. One AST.

What the compiler did:

1. Parsed the <schema> block (§39) and held the column list for users and posts in memory. 2. Parsed the <db> block (§52) and resolved the driver from the connection string (§44). 3. Parsed the ?{} SQL template (§8). The ${userId} interpolation compiled to a bound parameter, not string concatenation. There is no sql.raw() in scrml. 4. Resolved the query against the schema it just parsed. Note: the template is not syntax-checked at compile time today — see the accuracy note below. 5. Computed the migration diff between <schema> and the live database. scrml dev applies it on reload (schema-differ). The developer never writes ALTER TABLE by hand.

There is no prisma generate step. There is no generated client. There is no separate query DSL to learn. The query is SQL. The compiler reads SQL. The schema is in the same file the compiler is already parsing.

What the compiler can do that no ORM can

The schema introspection runs as a compiler pass. The data structure is paResult.protectAnalysis.views.get(stateBlockId).tables.get("users").fullSchema. It is a list of column names with types and primary-key/index status. It is sitting in memory the moment the LSP analyzes a buffer.

That fact unlocks features that are not reasonable to build in a piecewise stack:

  • Column completion against the live schema. Cursor inside a ?{} block, type SELECT u., the LSP suggests every column on users with its SQL type.
  • protect= field validation with quick-fix. A <db protect="passwrd"> (typo) becomes E-PA-007 at compile time. The LSP's L4 quick-fix runs Levenshtein over the column list and offers passwordHash (or whichever column you actually meant).
  • Schema-driven migration diff. The compiler reads what <schema> says, reads what the live database says, computes the SQL needed to walk one to the other, and emits it as a migration.
  • Bound-parameter enforcement is normative. ${expr} inside ?{} SHALL compile to a bound parameter. There is no opt-out. There is no .raw(). The grammar refuses.
  • Direct Bun.SQL codegen. A ?{} block emits a Bun.SQL tagged-template call. No runtime ORM layer. No prepared-statement cache to manage; Bun.SQL caches internally and .prepare() is removed (E-SQL-006).

The N+1 batching story (the intro article walks the numbers) is a separate piece of leverage that follows from the same fact: the compiler can rewrite a query inside a loop because it owns the loop and the query in one AST.

What gets refused at compile time

Accuracy note — corrected 2026-07-27

An earlier revision of this page listed six compile-time refusals. All six were probed against v0.7.1 (S287) by building each case and reading the emitted output. Three hold exactly as describedE-PA-007, E-SQL-004, and mandatory bound parameters. One had the wrong code (a bad tables= value is refused as E-PA-002, not E-PA-004). Two do not fire at all and are marked below.

The entries below now describe what the compiler does, not what it is specified to do. They will be restored when the checks land. The strongest claim on this page — that every ${} interpolation compiles to a bound parameter, with no textual path and therefore no injection vector — was tested directly against the driver and holds absolutely.

Six refusals worth naming — four enforced today, two catalogued but not yet firing:

E-PA-001 / E-PA-006. <db src="./missing.db"> where the file does not exist, or the src= attribute is missing entirely. The build fails before any query runs.

E-PA-002. <db tables="usrs"> where the table is misspelled or does not exist. The build prints the actual table list. (Verified 2026-07-27. Earlier revisions of this page cited E-PA-004 for this; the refusal is real, the code was wrong.)

E-PA-007. protect="passwrd" against a table whose actual protected column is password_hash. Compile error with a Levenshtein-ranked "did you mean password_hash?" quick-fix from the LSP.

E-SQL-002 — NOT ENFORCED TODAY. This code is catalogued for a SQL template that is syntactically invalid, but it does not fire. ?{`SELCT usrnme FRM users WHERE`} builds exit 0 and ships to the server bundle verbatim. Verified against v0.7.1 on 2026-07-27.

E-SQL-003 — NOT ENFORCED TODAY. This code is catalogued for constructing the SQL string at runtime, but it does not fire, and the failure mode is worse than a missing diagnostic: given const q = "SELECT …" then ?{q}.all(), the compiler emits the identifier as literal SQL — _scrml_sql`q`. Exit 0. The query the program runs is the one-character statement q. Verified 2026-07-27 and escalated to the compiler team.

E-SQL-004. A ?{} block with no <program db="..."> ancestor. The compiler cannot pick a driver out of thin air; it tells you which attribute is missing.

That last one is the structural point: the database is not a runtime concern that happens to need a driver. It is a compile-time fact the program declares.

What this kills

  • schema.prisma and equivalents. The schema is in <schema>. There is no second file. There is no second source of truth.
  • prisma generate and equivalents. There is no generated client. The compiler reads the schema directly.
  • Query DSL learning curves. No db.users.findMany({ where: {...}, include: {...}, orderBy: {...} }). SQL is the syntax. The whole-stack compiler reads it.
  • Type definitions that drift from the database. There is no hand-written interface to fall behind. The <schema> block is the type, the migration source, and the introspection source, in one declaration.
  • sql.raw()-style escape hatches that silently lose type safety. Bound parameters are mandatory and this holds absolutely — verified in the emitted artifact and against the driver: every interpolation binds, so there is no textual path and no injection vector. Raw construction is catalogued as E-SQL-003 but is not currently refused (see above).
  • Most of the "I'd reach for an ORM here" instinct. The instinct exists because raw SQL strings in a JavaScript file are unanchored. In scrml they are not unanchored. The schema is right there.

What is still real

This is not "ORMs are wrong." Honest list of what they earn:

Cross-database portability. Drizzle and Kysely both target multiple engines. If the same TypeScript codebase has to ship against Postgres in production and SQLite in tests, the DSL abstracts the dialect differences. scrml's ?{} adapts driver based on <program db="...">, so Bun.SQL handles SQLite, Postgres, and MySQL. MongoDB is explicitly out of ?{} (use ^{} meta context). So the portability story is real but bounded by Bun.SQL's coverage.

Migrations exist. scrml has them. They are computed by diffing <schema> against the live database, but they exist as their own artifact and scrml migrate is a separate command. The schema-first ORMs got this part right. The difference is who owns the source-of-truth declaration.

Transactions are deferred. Current spec workaround is ^{} meta with direct Bun.SQL sql.begin() (§44.6). A native scrml syntax for transactions is in the roadmap; until it ships, this is a real gap and worth naming honestly.

Specialized query patterns. Window functions, recursive CTEs, JSON operators, full-text search. SQL has all of these. So does ?{}, because ?{} is SQL. ORMs vary in how cleanly they expose them. The point is not that scrml is more powerful than every ORM at every query shape. The point is that the SQL string is the language the compiler reads, so the language is as expressive as SQL itself.

The honest summary is this: ORMs exist to plug a gap. The gap is real. They plug it well enough that mature stacks rely on them. But the gap exists because the language and the database are speaking different languages, and the schema lives in a different artifact from the code. When the compiler owns both, the gap is gone.

The deeper claim

A reactive system that wires its dependencies at compile time does no work at runtime to figure out what to update. A boundary that is enforced at compile time does not need a validator on the wire. A query that knows its schema at compile time does not need an ORM to translate intent.

The runtime does less because the compiler did more. The query layer stops being a place where types drift, where DSLs approximate SQL, where generated artifacts go stale, where 3am alerts fire because a column rename did not propagate. It starts being what it should have been from the start: SQL, anchored to a schema the compiler can read, in a file the compiler is already parsing.

That is the design. A little short of perfect is still pretty awesome.

Further reading