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

@malharmorey/ext0

v1.1.2

Published

Agentic workflow that turns plain-English requests into governed, read-only database queries and Excel-ready CSV reports. SQL and MongoDB, four safety layers, one dependency.

Readme

ext0

npm version license: MIT node

Self-serve data reports for business users. ext0 is a lightweight npm library whose agentic workflow converts natural-language requests into governed, read-only database queries: four layers of safeguards vet each one, and deterministic code holds the final veto.

Every company burns developer hours on report tickets. ext0 deletes that queue: business users ask for reports in plain English, and the agentic workflow hands back a CSV that opens straight in Excel. No SQL, no ticket, no waiting.

It is built on one principle: AI output is untrusted input. Every generated query must pass a hard-coded, parser-backed security gate that cannot be disabled, and the connection itself cannot write. The model proposes, code disposes.

Highlights

  • Built for Node + TypeScript servers, ships as a small ESM package with one runtime dependency
  • Database agnostic: SQLite, PostgreSQL, MySQL and MongoDB behind a single adapter interface
  • Four safety layers: LLM watchdog, hard-coded final gate, database dry-run, read-only connection
  • Confirm step: the user approves or corrects a plain-English restatement before anything runs
  • Field mapping: your app's form field names bind to real columns and return as the CSV headers
  • Multi-part questions answered by one staged query, or refused clearly when the parts share no link
  • Constant-memory CSV streaming: rows flow to disk one at a time, so peak memory stays flat at any row count
  • Zero-boilerplate setup: live schema introspection, built-in CSV writer, sensible defaults
  • Benchmarked honestly: Spider/BIRD-style execution accuracy plus a deterministic attack suite
  • One structured log line per report with a pluggable sink
  • Use it as plain function calls from the server you already run — a POST /report route is a copy-paste example

Architecture

request ──> WRITER ──> confirm? ──> WATCHDOG ──> FINAL GATE ──> EXPLAIN ──> RUN ──> CSV
             (LLM)     (user)        (LLM)        (code)         (DB)     (read-only)
               ▲                       │             │             │          │
               └────────── one bounded retry: rejection reason ◄──┴──────────┘

| Stage | Kind | Job | Constants | |---|---|---|---| | Writer | LLM, temp 0 | request + schema become a 1-line summary and ONE query | 30s timeout, 1 transport retry on 429/5xx | | Confirm | user | approve or correct the summary before anything runs | max 3 edit rounds, session TTL 10 min | | Watchdog (Guard 1) | LLM, separate call | judges the proposed query against the request, never writes queries | sees only request + query, fail-closed, can be disabled | | Final gate (Guard 2) | deterministic code | parse and prove: read-only, single statement, allowlist, forced row limit | always on, no flag exists, row limit default 50,000 | | EXPLAIN | database | dry-run plan check, invalid or over-broad queries die before any read | validity always, scan threshold tunable | | Run | read-only connection | the physical floor, cannot write even if everything above failed | | | CSV | zero-dep code | rows and documents become CRLF CSV, Excel-ready | streamed one row at a time, atomic write |

A rejection at any gate feeds its reason back to the writer for one regeneration (maxRetries, default 1). The pipeline is fixed code. The LLM never drives control flow.

Safety model

Two layers are load-bearing and deliberately dumb: the read-only DB account, which physically cannot write, and the final gate, which parses the query and proves its properties instead of pattern-matching text. The watchdog and the EXPLAIN threshold are helpful extras, never the only defense. The allowlist scopes what is readable down to individual columns; deny does the inverse when you only need to hide a few. Set one of them, or expose only curated views, whenever the connection can reach sensitive tables.

| Tunable | Hard-wired, no flag exists | |---|---| | rowLimit value (default 50,000) | that a row limit exists | | allow / deny: tables, collections, columns | the final gate always runs | | explainThreshold: max full scans in the plan | read-only enforcement | | guard1 on/off, maxRetries, maxConfirmRounds, sessionTtlMs | EXPLAIN validity check |

Project layout

src/
├── pipeline.ts        control flow: writer -> confirm -> guards -> explain -> run -> csv
├── writer.ts          Agent 1: request + schema -> summary + one query
├── llm.ts             plain-fetch provider calls (anthropic, gemini, openai-compatible), temp 0, 30s cap
├── guards/
│   ├── agentGuard.ts  Guard 1: watchdog LLM
│   └── finalGate.ts   Guard 2 entry: fail-closed defaults, always on
├── adapters/
│   ├── QueryTarget.ts the four-method seam every database plugs into
│   ├── sql.ts         sqlite / postgres / mysql
│   └── mongo.ts       JSON query descriptions, never code
├── fieldMap.ts        form-field mapping: load, prompt lines, header rename
└── csv.ts             zero-dep streaming writer
prompts/               writer and watchdog system prompts, editable and versioned
tests/                 181 offline cases
bench/                 accuracy, safety and memory harness
sample-db/             seeded SQLite company database
scripts/               runnable demos (sql, confirm flow, mongo)

Quick start

npm i @malharmorey/ext0

Wire a model and a read-only connection. Everything else has defaults.

import { createExt0, sqlAdapter } from "@malharmorey/ext0";

const ext0 = createExt0({
  llm: { provider: "anthropic", model: "claude-haiku-4-5", apiKey: process.env.ANTHROPIC_API_KEY },
  db:  sqlAdapter({ connection: readOnlyConnection }),
});

const report = await ext0.generateReport("products under 50 euros with name and price");
// { file: "reports/report-....csv", query, summary, meta }

That is the whole setup — the schema is introspected live from the connection. To limit what is readable, add allow (an allowlist) or deny (a blocklist); see the configuration reference.

Confirm flow

For end users you usually want the two-phase flow. The tool first restates what it understood, then the user confirms or corrects it.

const step = await ext0.interpret("orders from big customers last month");
// step.summary: "This will pull all orders placed in June 2026 ..."

const out  = await ext0.proceed(step.sessionId, { confirm: true });
const redo = await ext0.proceed(step.sessionId, { edit: "big means over 10k" });

Sessions are single-use, capped at 3 edit rounds, and expire after 10 minutes. They live in process memory, sized for a single server instance.

Serving over HTTP

ext0 is a library, not a server — call it from whatever server you already run. Build one instance at startup and reuse it (so the schema cache is shared), then a minimal POST /report route is all you need:

import { createServer } from "node:http";
import { createReadStream, unlink } from "node:fs";
import { basename } from "node:path";
import { createExt0, sqlAdapter } from "@malharmorey/ext0";

const ext0 = createExt0({ llm, db: sqlAdapter({ connection }) }); // built once, reused

createServer(async (req, res) => {
  let body = "";
  for await (const chunk of req) body += chunk;
  const { request } = JSON.parse(body);

  const { file, meta, summary, query } = await ext0.generateReport(request);
  if (!file) {
    res.writeHead(422, { "content-type": "application/json" });
    return res.end(JSON.stringify({ error: meta.blockedBy, summary, query, meta }));
  }
  res.writeHead(200, {
    "content-type": "text/csv; charset=utf-8",
    "content-disposition": `attachment; filename="${basename(file)}"`,
  });
  createReadStream(file)
    .pipe(res)
    .on("finish", () => unlink(file, () => {})); // the download is the delivery
}).listen(3000);

Configuration reference

| Key | Default | Meaning | |---|---|---| | llm | required | { provider, model, apiKey } — anthropic, gemini, or an OpenAI-compatible provider (openai, groq, openrouter, mistral, deepseek, xai, ollama); or your own (system, user) => Promise<string>. Plain fetch, no SDK | | db | required | a QueryTarget adapter | | allow | "*" | "*", or { table: "*" \| ["col", ...] }, tables/collections and columns readable | | deny | none | inverse of allow: { table: "*" \| ["col", ...] } hides whole tables/collections or single columns; mutually exclusive with allow | | rowLimit | 50000 | forced cap on returned rows, value tunable, existence not | | maxRetries | 1 | regenerations after a rejection | | maxConfirmRounds | 3 | edit rounds per confirm session | | sessionTtlMs | 600000 | confirm session lifetime | | guard1 | true | the watchdog LLM call, disable to save one call per report | | explainThreshold | unset | max full-table scans allowed in the plan, unset means validity check only | | prompt | built-in | full writer system-prompt override, {{schema}} substituted | | rules | none | house rules appended to the prompt ("fiscal year starts in February") | | fieldMap | none | form-field to column mapping | | onLog | stdout | per-report log sink | | outDir | "reports" | where CSVs are written | | reportTtlMs | unset | sweep our own report-*.csv older than this on each report; off by default | | schemaCacheMs | unset | reuse the introspected schema for this long instead of re-reading it every request |

Field mapping

Your app's form has fixed field names and users phrase requests with them. fieldMap binds each form field to exactly one column. One-to-one, no synonyms, duplicates rejected at load.

fieldMap: { "Customer Name": "customers.name", "Revenue": "v_finance.revenue" }
// or a CSV the business maintains in Excel:
fieldMap: "./fields.csv"   // header: field,column

The mapping is injected into the writer's context, filtered to allowlisted tables, and result headers are renamed back to the form field names in plain code. The CSV says Revenue, not revenue_recognized. Aggregated and derived columns are covered too: the writer labels every output column with a human-readable name, so no raw column name reaches a header. If a field could come from several sources, map it to the one canonical column, or allowlist only the curated view so the ambiguity never reaches the model.

Database adapters

The pipeline talks to data through a four-method interface. Adding a database means one file.

interface QueryTarget {
  describeSchema(): string | Promise<string>;                    // context for the writer
  validate(query, { allow, rowLimit }): ValidationResult;        // the final gate, pure code
  explain(query, { threshold? }): ExplainResult | Promise<...>;  // DB dry-run
  run(query): AsyncIterable<Row> | Promise<AsyncIterable<Row>>;  // read-only, streamed row by row
}

| Adapter | Query form | Gate enforces | EXPLAIN | Introspection | |---|---|---|---|---| | sqlAdapter (sqlite) | SQL, parsed to AST | single SELECT, allowlist across subquery/UNION/JOIN/CTE, column rules with alias resolution, LIMIT forced into the AST | EXPLAIN QUERY PLAN, SCAN count | sqlite_master, tables and views, value hints | | sqlAdapter (postgres) | same | same, parsed with the pg grammar | EXPLAIN (FORMAT JSON), Seq Scan count | information_schema | | sqlAdapter (mysql) | same | same, parsed with the mysql grammar | EXPLAIN, type=ALL count | information_schema | | mongoAdapter | JSON description, never code | find XOR aggregate, $where/$function/$accumulator/$out/$merge banned at any depth, $lookup/$unionWith sources allowlisted, limit forced on both shapes, restricted collections require an inclusion projection | cursor explain, COLLSCAN count | collection sampling |

Drivers are never bundled. Drivers with a sqlite-like shape pass a connection. PostgreSQL and MySQL pass one async function wrapped around your own driver:

sqlAdapter({ dialect: "postgres", run: async (sql) => (await pool.query(sql)).rows });
sqlAdapter({ dialect: "mysql",    run: async (sql) => (await conn.query(sql))[0] });

The PostgreSQL and MySQL paths are currently verified against recorded driver output rather than live servers. The MongoDB adapter is verified against a live Atlas cluster.

Multi-step requests

Several related questions in one prompt still produce ONE statement. The writer stages the work with CTEs on SQL or pipeline stages on Mongo and returns one combined result, and the gate rejects multiple statements regardless of intent.

"revenue per region and each region's best-selling product"
-> WITH region_revenue AS (...), product_sales AS (... ROW_NUMBER() ...) SELECT ...
-> one CSV

Error contract

Two channels by design.

| Channel | How it surfaces | Examples | UX handling | |---|---|---|---| | Refusal, the query was the problem | resolves normally, file absent, reason in meta.blockedBy | allowlist violation, over-broad plan, watchdog rejection | show blockedBy and summary, ask the user to narrow or rephrase | | Exception, rephrasing cannot fix it | throws, the message says what to do | expired session, edit cap, LLM or DB down, config errors at startup | map "expired" and "rephrase" to friendly prompts, everything else to "temporarily unavailable" |

Refusal strings are written to be shown to people: "table employees is not in the allowlist", "query plan needs 3 full table scans (allowed: 1), narrow the query".

Observability

Every report emits exactly one structured line, success and refusal alike:

[ext0] {"time":"...","ok":true,"request":"...","query":"...","rows":49,"retries":0,"ms":4880}

The default sink is stdout, which Cloud Run and similar platforms capture natively, making retention a platform setting. Pass onLog: (entry) => ... to route entries anywhere. A throwing sink never breaks a report.

Benchmark

A committed 18-case set (natural-language request plus gold query), scored as Execution Accuracy (the Spider/BIRD EX metric): predicted and gold result sets must match, so differently phrased but correct SQL passes. Safety is a separate deterministic suite fed straight to the gate with no LLM in the loop.

| model (temp 0) | cases | execution | result match (EX) | safety | p50 / p95 | |---|---|---|---|---|---| | claude-haiku-4-5 | 18 | 100% | 72.2% | 24/24 | 5.6s / 15.8s | | claude-haiku-4-5, after prompt fix | 18 | 100% | 100% | 24/24 | 4.2s / 5.3s |

The first run's misses shared one cause, a prompt rule that added an identifier column the request did not ask for. One line fixed it. Both rows stay in BENCHMARKS.md because the number should be re-earned, not curated. The set is small and grows each version. pnpm bench reruns it.

The offline test suite holds 181 cases: SQL gate attacks (destructive statements, statement smuggling, PRAGMA/ATTACH/VACUUM escapes, allowlist escape via subquery/UNION/JOIN/CTE, limit stripping), Mongo gate attacks ($where injection, $out and $merge, lookup escapes, projection rules), dialect cases, access scoping, and the pipeline, confirm, session, log, streaming and field-map suites. A query that fails the gate never reaches a connection, in tests included.

Development

pnpm i && pnpm seed
pnpm test                 # offline suites, accuracy needs an API key
pnpm demo "products under 50 euros with name and price"
pnpm mongo-demo "total order value by status"
pnpm bench                # EX benchmark
pnpm bench:safety         # 24-attack gate suite
pnpm lint
pnpm build

License

MIT