npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nlp2sql

v0.1.0

Published

Ask your Postgres questions in plain English, from inside your own application. Curated catalog, real-PostgreSQL-parser validation, read-only tenant-scoped execution.

Readme

nlp2sql

Ask your Postgres questions in plain English, from inside your own application.

import { NL2SQL, anthropic, catalogFromDir, postgres } from "nlp2sql";

const engine = new NL2SQL({
  catalog: await catalogFromDir("./catalog/acme"),
  db: postgres({ connectionString: process.env.DATABASE_URL }),
  model: anthropic(),
});

const answer = await engine.ask("revenue by store last quarter", { tenant: session.orgId });

answer.sql     // the SELECT it ran, parsed and allowlisted before execution
answer.rows    // [{ store_name: "Camden", revenue_gbp: "48210.00" }, ...]
answer.chart   // { kind: "bar", x: "store_name", y: ["revenue_gbp"], unit: "gbp" }

No sidecar, no VPS, no Docker, no second database. It is a library: your connection and your model key stay in your process, and no row of your data ever leaves your infrastructure — not to us, and not to the model provider.

The model never sees your schema. It sees a catalog: a handful of curated reporting views with declared grain, units, synonyms and forbidden combinations. Every query it writes is parsed with the real PostgreSQL grammar, checked against an allowlist, cost-gated, and executed read-only inside a tenant-scoped transaction.


Status

Not published. Phases 1–7 of the plan are built and green — 173 tests, clean typecheck, a working dist build. The package name is settled (nlp2sql, free on npm); what remains is that "private": true is still set in package.json, because removing it is the act of publishing and that needs a human.

Everything else — exports, files, types, the prepublishOnly check — is in place. npm run check typechecks, tests and builds.

Note the bare nl2sql on npm is a different, actively maintained project. The exported class here is still NL2SQL; only the package name differs.

Install

npm i nlp2sql pg                        # a connection string
npm i nlp2sql @aws-sdk/client-rds-data  # SST/Aurora, no DSN at all
npm i nlp2sql                           # Supabase needs no extra package

Node 20+. ESM only. TypeScript types are first-class, not an afterthought.

Two ways to use it

Both supported, both documented, neither the escape hatch.

Managed — the library makes the model call

const answer = await engine.ask(question, { tenant });
if (answer.ok) render(answer.rows, answer.chart);
else if (answer.status === "abstained") say("I can't answer that.");

You get retrieval, the repair-once loop, cost gating, rate limits and tracing.

Primitives — you make the model call

const retrieval = engine.context(question);       // what to tell the model
if (retrieval.abstain) return refuse(retrieval.abstainReason);

const draft = await myModel(retrieval.context);   // your provider, your tracing
const safe  = await engine.validateSql(draft.sql);
const rows  = await engine.runQuery(safe, { tenant });
const chart = engine.chart(rows);

Use this for streaming, your own observability, a provider we do not ship, prompt caching you control, or — the big one — more than one query per question. We are not shipping an agent framework; you already have a for loop, and it is better than a framework we invent.

runQuery accepts only a SafeSQL, which only validateSql can mint. A model's raw string cannot reach your database by mistake, and the compiler is what says so.

See examples/.

Answer

type Status = "answered" | "abstained" | "rejected" | "error" | "rate_limited";

| Status | Means | You should | |---|---|---| | answered | validated SQL ran | render | | abstained | outside the catalog. No model call was made | say "I can't answer that" — not "error" | | rejected | the model's SQL was refused, or too expensive | log it; this is a catalog gap | | error | Postgres refused, after one repair attempt | log; error carries the message | | rate_limited | a Limits counter tripped | back off |

ask() never throws for anything a user can cause. Programmer errors — a malformed catalog, no model configured — still throw.

answer.assumptions is the honesty channel: when a question is underspecified the model must declare what it assumed ("unit=gbp", "year=2026"), and your UI should show it.

Adapters

postgres({ connectionString })                   // the base case
postgres({ pool: app.db })                       // the pool you already have
rdsData({ resourceArn, secretArn, database })    // SST/Aurora: no DSN at all
supabase({ url, serviceKey })                    // no DSN, no database password

Each declares what it can actually do rather than pretending:

| | explain | readOnlyTx | setLocal | |---|---|---|---| | pg | yes | yes | yes | | rds-data | yes (5 round trips per query) | yes | yes | | supabase | no cost gate | inside the RPC | inside the RPC |

postgres({ pool }) is the quiet best answer to "we don't keep a DATABASE_URL around": hand over the pool you use for everything else. It is safe because the transaction is BEGIN READ ONLY — a write is refused by Postgres itself even on a connection that could perform one.

Numerics. pg returns numeric and bigint as strings, the Data API returns typed fields, Supabase returns JSON numbers. Silently turning 120.50 into 120.5 is the exact failure this project exists to prevent, so the default is exact ("string") and numeric: "number" is an explicit opt-in.

Training data

The reason to build a library rather than a service. Correcting a wrong answer is four calls, not a pull request:

await engine.feedback(answer.id, "dislike", { expected: "should exclude refunds", tenant });

const [candidate] = await engine.examples.candidates();
await engine.examples.promote(candidate, { sql: correctedSql, reviewedBy: "you" });

const after = await engine.evaluate({ tenant });
after.regressions(before);          // did fixing that break three others?

await engine.examples.export("./catalog/acme/gold.yaml");   // git, when you want it

A promoted example is live for the very next question. Promotion requires a human to supply the correct SQLpromote() takes sql and reviewedBy and there is no overload that omits them. That gate is kept; what changed is that passing through it takes one call instead of a release cycle.

Stores: MemoryStore (default — answers and forgets, which is correct for a first evaluation), sqliteStore(path), postgresStore({ pool, schema }).

The recommendation is your own database in a dedicated nl2sql schema — your data, your backups, queryable beside your application tables. Note it needs a writable connection while the query path is read-only: two connections, not one, which costs nothing when your app already has a writable pool.

Traces never keep your data. No result rows, ever. The tenant is hashed with the engine name. SQL keeps its shape and loses its literals — WHERE email = '[email protected]' becomes WHERE email = ? — and SQL we could not parse is dropped rather than stored raw, because literals we cannot find are literals we cannot promise are gone.

Security

| Layer | Guarantee | |---|---| | Catalog | the model never sees raw tables, raw columns, or deny: true columns | | Validator | real PostgreSQL parse; one statement; SELECT only; view, column and function allowlists; no cross-view joins; LIMIT injected into the tree | | SafeSQL | runQuery cannot be handed a raw string — the compiler refuses | | Read-only transaction | BEGIN READ ONLY; a write is refused by Postgres itself | | Timeouts | statement_timeout and idle_in_transaction_session_timeout, both SET LOCAL | | Cost gate | EXPLAIN estimate checked before execution, where the adapter supports it | | Tenant | bound as a query parameter by trusted code, per transaction. Never in the prompt | | Caps | row count and byte size, enforced after fetch |

Result rows are never sent to the model. It sees the question, the catalog, the retrieved examples, and — on a repair — the Postgres error string.

What this does not defend against, stated plainly: authorization within a tenant is yours (deny: true removes a column entirely; there is no per-role visibility); a prompt-injected question can make the model write WHERE 1=1, which cannot escape the tenant predicate or reach a view outside the catalog but does control filters inside it; and your model provider sees the question text.

The validator

Built on pgsql-parser, the actual PostgreSQL C parser compiled to WebAssembly, so "would Postgres read this the way we think?" is answered by Postgres. Among the things it refuses that a first-keyword check would not: WITH ins AS (INSERT ... RETURNING ...) SELECT ..., which writes to your database from a statement beginning with SELECT.

validate() is async because the WASM module loads on first use — call engine.warmup() at startup to move that off the first question. The parser is ~3.5 MB installed (1.1 MB WASM): fine server-side, not something for a browser.

Development

npm test          # 173 tests
npm run typecheck
npm run build
npm run check     # all three

The executor and store tests need a real server, because "a write is refused" is not a claim you can unit-test. They look for NL2SQL_TEST_DSN, then initdb in NL2SQL_PG_BINDIR / the Python virtualenv / on PATH. Finding neither, they skip loudly rather than passing quietly. Locally there is nothing to install — pgserver, already a Python dev dependency of this repository, ships real Postgres binaries and the harness starts a throwaway cluster on a Unix socket.

Why it lives in the Python repository, for now

../spec is the contract both runtimes are held to, and it is not published yet. Reading it from the same checkout keeps the loop tight: change the engine, regenerate the spec, and this suite fails immediately if the two have diverged. Extraction is one git mv at publish time. Nothing here imports anything Python.

The pyrepr.ts wart

The Python reference renders a view's default_rules into the prompt with an f-string, so what reaches the model is a Python dict repr: {'unit': 'gbp', 'year': 2026}. Emitting JSON here would send the model different bytes, and two runtimes sending different prompts cannot share an accuracy result. So it is reproduced exactly, kept in one labelled file, and recorded as a divergence in ../spec/README.md to be normalised later — behind an eval run, because changing the prompt changes answers.