5 min read

You Can Now Do SQL-Only Development in Node

noorm 1.0 is out: your sql folder is the schema, changes are plain SQL forward/revert pairs, and the SDK keeps Kysely's typed queries. No DSL, no codegen, no ORM.
noorm, the SQL-first schema and change management tool for Node.js

Every ORM thread has the same comment buried in it somewhere: "I just want to write SQL. I only use a query builder because nothing handles SQL-only migrations."

That was me two years ago. I stopped using ORMs and needed something to run the SQL. That is the whole origin. I'm stubborn, so instead of keeping a SQL file plus a .ts migration file for every change, I wrote my own private scripts and moved on.

The scripts kept hitting walls that not even Kysely answers: testing against a real database, seeding, keeping four environments apart, onboarding anyone who wasn't me. At some point I accepted that nobody was going to solve this publicly and started building.

Seven months and 40 alpha releases later, it hit 1.0. I'm calling it "noorm" ... yes ... literally "NO ORM!" I'm THAT stubborn. It lives at noorm.dev, it's Apache 2.0, and it speaks Postgres, MySQL, SQLite, and SQL Server.

Your sql folder is the schema

The whole idea is one inversion. Migration tools make you describe your schema twice: once in the migrations that built it, and once in your head, as the state you believe replaying them produces. In noorm, your sql/ folder is the current state. A fresh database runs the files in alphanumeric order and is done. No replaying two years of migrations.

sql/
├── 01_tables/
│   ├── 001_users.sql        # CREATE TABLE users...
│   └── 002_posts.sql        # CREATE TABLE posts...
└── 02_views/
    └── 001_recent_posts.sql # CREATE VIEW recent_posts...

An existing database catches up differently. A change is a folder holding forward and revert scripts, both plain SQL:

changes/
└── 2024-01-15-add-user-roles/
    ├── change/
    │   └── 001_add_role_column.sql
    └── revert/
        └── 001_remove_role_column.sql

Every applied change is checksummed and recorded: its status, when it ran, who ran it, and the actual error message when it failed. That last one is the part even ORMs skip. When a change fails, you fix it, and you run it again, something that broke during the failed attempt may not surface for days. A history that kept the error lets you trace back to the run that went wrong instead of guessing.

No DSL, no codegen, no describing your schema twice. `noorm change ff` in CI and every database is caught up.

Why raw SQL at all

Because the data model is the decision every other decision inherits, and ORMs quietly make it for you: a surrogate ID on every table, a join for every question. Plain SQL gives you back the tools ORMs can't reach, and the clearest one is inherited keys.

CREATE TABLE users (
    user_id     serial      PRIMARY KEY,
    email       text        NOT NULL UNIQUE,
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE todos (
    user_id     int         NOT NULL REFERENCES users (user_id),
    todo_no     int         NOT NULL,
    title       text        NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now(),

    PRIMARY KEY (user_id, todo_no)
);

CREATE TABLE todo_items (
    user_id     int         NOT NULL,
    todo_no     int         NOT NULL,
    item_no     int         NOT NULL,
    body        text        NOT NULL,
    done        boolean     NOT NULL DEFAULT false,

    PRIMARY KEY (user_id, todo_no, item_no),
    FOREIGN KEY (user_id, todo_no) REFERENCES todos (user_id, todo_no)
);

No todo_id, no todo_item_id. A todo is identified by whose it is and which of theirs it is; an item by which list it sits on and where in that list. todo_no counts within its user and item_no within its list: plain columns the application assigns, not identity or autoincrement, which can only number globally. Note created_at is still there; it just went back to being data instead of identity. Two things follow from the keys, and neither costs any application code.

todo_items carries user_id on every row, two levels down from where it was introduced, so finding the owner of an item is a column read, not a join. And the composite foreign key makes cross-tenant corruption unrepresentable: an item cannot reference a list belonging to a different user, because the user_id in the item's own key has to match the one in the list's key. The schema enforces what application code has to remember. I've made that argument before; this is the same principle one layer down.

The full argument, including what proper relational design does instead of polymorphic associations, is in the relational design guide.

What two years of walls became

None of noorm was designed up front. It was excavated. Each row is a question I could not answer at the time, and what answering it turned into:

Question What it became
How do I run SQL? A directory of files, executed in order
How do I undo one? Forward and revert pairs, tracked per change
How do I test against it? A real database with safety guards, not mocks
How do I express what DDL makes painful? Templates that render SQL from YAML
How do I seed data? Templates again, plus transfer between databases
Where does connection configuration live? Configs you can export and hand to a teammate
How do I onboard someone onto four environments? Stages, with required and optional variables
Where do team secrets live? An encrypted vault table inside the database
What ran, by whom, and why did it break? History that records the operator and the error
How do I re-run the idempotent objects? Manifests

On top of those: the TypeScript SDK is built on Kysely, so you keep typed queries and gain typed stored procedures and table-valued parameters. If you're on Kysely today, this is the migration half you were missing, not a replacement. The interactive half lives in a TUI; everything automatable runs headless with --json for CI.

The noorm TUI adding a config, creating a database, building the schema, fast-forwarding changes, and browsing the result
Adding a config, creating the database, building the schema, fast-forwarding changes, browsing the result.

Where agents fit

Halfway through the alphas, the constraint flipped. Everyone, including me, was busy with LLMs, and an agent started writing most of my SQL. Writing SQL stopped being the slow part. The slow part became reviewing SQL that an agent wrote quickly, confidently, and sometimes wrongly.

So noorm runs as an MCP server. noorm mcp init wires it into Claude Code or Cursor, and the agent gets the same command set the TUI uses: schema exploration, queries, changes, builds. There is also an agent skill that teaches it the conventions, because MCP is what the agent can do and the skill is what it knows.

The interesting part is what happens before anything executes. Every config declares a role per channel, access: { user, agent }, and when an agent submits SQL, noorm parses the statement and classifies what it actually does, as DQL, DML, or DDL, before it runs. The config's agent role then accepts or denies that class: a viewer agent gets a SELECT through and a same-shaped INSERT denied. Wrappers don't fool it either. EXPLAIN ANALYZE DELETE runs the delete, so it is classified as the write it is.

access:
    user: admin      # what you get in the CLI/TUI
    agent: viewer    # what any connected agent gets
  • viewer: explore the schema, run read-only SQL. The default for any config that never declared access.
  • operator: adds DML. DDL and destructive commands stay out of reach.
  • admin: DDL, changes, builds. For databases you can afford to lose.
  • false: the config does not exist, as far as the agent can tell.

Prompts ask; roles enforce. An agent that gets refused over MCP and shells out to the CLI is still an agent, and it lands on the same role. Give the disposable dev database admin so the agent can rebuild and test all day, keep production on false so it cannot even see it, and the policy holds no matter what the prompt says.

The tradeoff

Altering a table is two edits: update the schema file so a fresh build stays true, and write a change so existing databases catch up. I have not found a way around that without losing "a fresh database just runs the files," and I'll take the second edit over replaying history every time. If you see a way around it, I want to hear it.

Two years of private scripts, seven months of alphas, and the tool I always wanted for doing SQL in TypeScript is public and stable. If the only reason you use a query builder is that nothing handled SQL-only migrations, that reason is gone.

Stay in the Loop!

Be the first to know - subscribe today