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

@speles7172/sql-client

v0.4.0

Published

Multi-engine SQL client for AWS-hosted databases, with a typed connection registry.

Readme

@speles7172/sql-client

Run queries against AWS-hosted databases from any application, over a registry of named connections.

npm install @speles7172/sql-client

Four engines behind one API:

| Engine | Reached by | Use for | |---|---|---| | postgres | direct socket | a database this process can open a connection to | | remote-pg | bridge Lambda, or direct | PostgreSQL in another VPC or region | | athena | Athena API | querying S3 data | | neo4j | bridge Lambda | graph queries |


Register, then query

Registration is where a client declares which database, which engine, and which version it built against — the version is validated, not guessed, so a wrong assumption fails at startup rather than on a query months later.

import { registerDatabase, createSqlClient } from '@speles7172/sql-client';

registerDatabase({
  name: 'main',
  engine: 'postgres',
  version: '17',
  secretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:main-db',
  region: 'us-east-1',
});

const db = createSqlClient();

const users = await db.rows('main', 'SELECT id, email FROM users WHERE active = $1', {
  params: [true],
});

rows() returns the rows, one() the first row or null, and query() the full result including fields and counts.

Databases with expiring credentials (RDS IAM)

IAM authentication signs a password that is valid for about 15 minutes, so it is not a value you can store. Pass a function instead, and say how long what it returns stays good:

import { Signer } from '@aws-sdk/rds-signer';

const signer = new Signer({ hostname, port: 5432, username: 'app', region: 'us-east-1' });

registerDatabase({
  name: 'main',
  engine: 'postgres',
  version: '17',
  host: hostname,
  user: 'app',
  database: 'app',
  password: () => signer.getAuthToken(),
  credentialTtlMs: 10 * 60_000,   // re-sign well before the 15-minute expiry
});

The client re-mints the token when it expires and rebuilds the pool. Both halves matter: a pool keeps the password it opened with, so a token that expires mid-life leaves a pool that looks healthy while every new connection fails to authenticate.

Signing is local — no network call — which is what lets a Lambda in an isolated private subnet reach the database with no NAT gateway and no VPC endpoint.

Registering from the environment

Deployments that already carry a DB_CONNECTIONS JSON variable can load it as-is. PostgreSQL's own spellings (dbname, username) are accepted alongside this package's (database, user).

import { loadFromEnv } from '@speles7172/sql-client';

loadFromEnv(); // reads DB_CONNECTIONS
{
  "main":      { "secretArn": "arn:...", "region": "us-east-1", "version": "17" },
  "reporting": { "engine": "remote-pg", "secretArn": "arn:...", "region": "us-west-2",
                 "bridgeFunctionName": "db-bridge" },
  "lake":      { "engine": "athena", "outputLocation": "s3://results/", "database": "analytics" }
}

Pagination

Supplying page or pageSize turns it on. The statement is wrapped, not appended to, so a query with its own LIMIT, ORDER BY or UNION keeps its meaning:

const page = await db.query('main', 'SELECT * FROM events ORDER BY at DESC', {
  page: 2,
  pageSize: 50,
});
// { command: 'SELECT', rows: [...], totalCount: 4213, page: 2, pageSize: 50 }

LIMIT/OFFSET are interpolated because PostgreSQL accepts no placeholder there — they are integers this package computes, never caller text. Multiple statements are rejected rather than wrapped.


Running statements a human typed

query() is for an application talking to its own database: it does exactly what it is told. explore() is for anything a person types into a UI — a SQL console, an admin tool — where the default has to be that nothing is written by accident.

const result = await db.explore('main', sql, { maxRows: 500 });

It runs the statement on one connection, inside a transaction that is:

  • READ ONLY unless writeMode, so a stray UPDATE fails at the engine rather than appearing to work and being silently undone — the user sees an error instead of believing a write happened;
  • rolled back unless writeMode, which covers what READ ONLY does not, such as a function with side effects;
  • bounded by a transaction-local statement_timeout, so a runaway query cannot pin the connection;
  • capped at maxRows (1,000 by default, 10,000 ceiling), with truncated: true on the result when rows were dropped.

To let a statement persist, ask for it explicitly:

await db.explore('main', sql, { writeMode: true });

One statement per call. A batch is refused, because PostgreSQL's simple query protocol would run all of it — so COMMIT; DELETE FROM users would end the sandbox's transaction and then delete outside it, while the rollback at the end quietly did nothing. A semicolon inside a string literal or a comment is still fine; the check is a scanner, not a search for ;.

This is a second line of defence, not the first. The real guardrail is the database grant: a role with SELECT/INSERT/UPDATE/DELETE and no DDL cannot be talked into DROP TABLE by any client, whereas everything above is enforced by code that could have a bug in it. Give the console its own role.

Athena has no transactions, so explore() refuses there rather than implying a safety net that does not exist — restrict the query role instead.

Importing a CSV

importRows() loads parsed rows into a table. Unlike explore(), it commits.

const result = await db.importRows('main', {
  table: 'subscribers',
  mode: 'insert',
  errorMode: 'skip',
  matchColumns: [],
  fields: ['First', 'Last', 'Email'],   // the CSV headers
  rows: [['Ada', 'Lovelace', '[email protected]']],
  mappings: [
    { column: 'id', csvField: null, expression: 'gen_random_uuid()::text' },
    { column: 'name', csvField: 'First', expression: "{value} || ' ' || {Last}" },
    { column: 'email', csvField: 'Email', expression: null },
  ],
});
// → { total: 1, applied: 1, errorCount: 0, errors: [], rolledBack: false, … }

Mappings. Each target column takes a CSV column, a SQL expression, or both:

| csvField | expression | Result | | --- | --- | --- | | 'Email' | null | The cell, verbatim | | null | 'now()' | A constant — no cell is read | | 'Age' | '{value}::int' | The cell, transformed | | 'First' | "{value} \|\| ' ' \|\| {Last}" | Several columns combined |

{value} is the selected csvField's cell; {Header} is any other column by header. Both compile to bind parameters — file content never reaches the SQL text, so a header crafted to close a quote and append its own statement is inert. Braces inside string literals are left alone, so '{new,pending}' stays an array literal.

The two contradictory pairings are rejected rather than guessed at: {value} with no csvField has nothing to bind, and a csvField whose expression never reads it would silently drop the cell. A CSV with two columns of the same name is rejected for the same reason — resolving it to one of them would import that column's data and discard the other's without saying so.

Errors. Every row runs inside its own savepoint, so one bad row cannot poison the rest of the file:

  • errorMode: 'skip' — failures are recorded, everything else commits.
  • errorMode: 'rollback' — every row still runs, so you get the complete error list, but nothing persists and applied is 0.

In update mode, matchColumns locate the row and the remaining mappings are what gets set. A row that matches nothing is reported as an error, not counted as a success.

Limits. 5,000 rows per import (MAX_IMPORT_ROWS) — the file is one transaction, which also keeps the payload under API Gateway's 10 MB. At most 50 errors are returned (IMPORT_ERROR_CAP); errorCount is the true total.

Mapping expressions are free SQL, at the same trust level as explore(). They come from the operator, not the file — but the grant on the connecting role is still what bounds them.

Postgres only. Other engines throw UNSUPPORTED: without savepoints, one bad row would abandon the whole file, which is a different feature.

Auditing every statement

const db = createSqlClient({
  onQuery: (event) => auditLog.write(event),
});

Called after every statement, successful or not, with the database, engine, SQL, parameters, duration, row count and any error — plus writeMode for explore().

A hook rather than a table, because "audit" means a different thing in every application. Failures inside the hook are swallowed: a logging sink that is down must not turn a working query into a failed one. If auditing has to be mandatory, enforce that where you call the client.

In Lambda

Lambda freezes the container the moment a handler returns, so pool idle timers never fire. Without cleanup, every warm instance holds its connection for the container's life and concurrent instances exhaust the database's connection limit.

import { createSqlClient, withSqlCleanup } from '@speles7172/sql-client';

const db = createSqlClient();

export const handler = withSqlCleanup(db, async (event) => {
  const user = await db.one('main', 'SELECT * FROM users WHERE id = $1', {
    params: [event.userId],
  });
  return { statusCode: 200, body: JSON.stringify(user) };
});

The client detects Lambda and sizes pools to a single connection there. Disposal runs whether the handler returned or threw, and never masks its outcome.


Behaviour worth knowing

Retries are narrow on purpose. "The connection failed" hides two different situations, and only one is safe to re-send:

| Failure | Example | Retried? | |---|---|---| | Connection never obtained | timeout exceeded when trying to connect, ECONNREFUSED | Always — the server provably never saw the statement | | Connection died mid-flight | ECONNRESET, EPIPE, socket hang up | Only if you pass replayable: true |

The second case is genuinely ambiguous: the server may have received, executed and committed the statement before the socket dropped, and the client cannot tell. Re-sending an INSERT there would apply it twice with nothing downstream able to detect it.

You have to declare it, because it cannot be read off the statement. These both look like reads and both write:

SELECT record_access(user_id) FROM sessions            -- volatile function
WITH moved AS (INSERT INTO archive … RETURNING *)      -- data-modifying CTE
  SELECT * FROM moved

For a genuinely pure read:

await db.rows('main', 'SELECT id FROM users', { replayable: true });

DATE stays a string. node-postgres turns a DATE into a JS Date at local midnight, so 2026-02-10 read from a US timezone reports as Feb 9. Dates without times are calendar values and are returned as YYYY-MM-DD.

Registration fields beat secret fields. That is what lets one RDS secret serve several registrations — point at a different database on the same cluster and override only that.

Errors name the database. A driver error alone says "syntax error at or near"; QueryError carries the database name and the statement. Unknown database names throw UnknownDatabaseError, which lists what is registered.


Credentials and TLS

No credentials are stored by this package. Secrets are read from AWS Secrets Manager via the ambient credential chain and cached per database; call resolver.invalidate() after a rotation. A registration may instead carry host/user/password directly, which is intended for local development.

TLS defaults to encrypted but not server-authenticated. Amazon RDS presents a certificate signed by a private Amazon CA that is not in Node's trust store, so verifying by default would break every RDS connection on first use — and the reflexive fix people reach for is disabling TLS altogether.

On any network where interception is a concern, verify. Supplying a ca turns verification on:

import { readFileSync } from 'node:fs';

registerDatabase({
  name: 'main',
  engine: 'postgres',
  secretArn: 'arn:…',
  // https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
  ssl: { ca: readFileSync('global-bundle.pem', 'utf8') },
});

| ssl | Behaviour | |---|---| | true (default) | Encrypted, server not authenticated | | false | No TLS | | { ca } | Encrypted and verified against that CA | | { rejectUnauthorized: false } | Same as true, stated explicitly |

Athena

Athena is a job API, not a connection: the client starts an execution, polls until it settles, then reads results. Two consequences worth knowing:

Paging is cursor-based. Athena has no OFFSET; later pages are reachable only by following NextToken in order, so requesting page 5 costs five calls. Deep paging over Athena is a bad idea, not merely a slow one.

Page size is capped at 999, one below Athena's own limit: Athena repeats the column names as the first row of page one and counts it against MaxResults, so page one needs room to ask for that extra row — otherwise it would return one row fewer than every other page.

Without pagination, every page is collected up to maxRows (default 10,000). Reaching that ceiling sets truncated: true on the result rather than quietly returning a prefix.


API

| | | |---|---| | registerDatabase(reg) · registerDatabases([...]) | add to the shared registry | | loadFromEnv({ variable }) | load DB_CONNECTIONS-style JSON | | new DatabaseRegistry(opts) | an isolated registry | | createSqlClient(opts) | a client bound to a registry | | client.query(db, sql, opts) | full result | | client.rows(db, sql, opts) · client.one(...) | rows, or the first row | | client.databases() | what this client can reach | | client.dispose() | close every pool | | withSqlCleanup(client, handler) | Lambda wrapper |

Engines, the pagination helpers and the AWS adapters are exported too, for callers building their own wiring.