postgres-kit
v0.0.5
Published
postgres-kit is a lightweight and easy-to-use PostgreSQL client library for Node.js, providing a simple and intuitive API for interacting with PostgreSQL databases.
Maintainers
Readme
postgres-kit
A lightweight PostgreSQL database toolkit for TypeScript and Node.js, built on top of pg.
postgres-kit provides a simple Executor abstraction for SQL execution, querying, transactions, scalar queries, batch execution, parameter handling, result mapping, and boolean conversion.
It stays close to SQL and PostgreSQL rather than trying to become an ORM.
Application
│
▼
DB / Transaction interfaces
│
├── PoolManager
│ └── pg.Pool
│
└── PoolClientManager
└── pg.PoolClient
│
▼
Low-level functions
├── execute()
├── query()
├── queryOne()
├── executeScalar()
├── count()
└── executeBatch()
│
▼
pgIn the core-ts ecosystem, postgres-kit is the PostgreSQL adapter. It provides execution utilities, repositories, batch processing, stream processing, health checks, and PostgreSQL-specific implementations while reusing the database-independent abstractions from sql-core.
Example
- admin: SSR Admin Application
- admin-service: Admin Backend Microservice
- sql-simple-modular-sample: RESI API with express and postgres
Features
- PostgreSQL connection pool management
- Simple
Executorabstraction - Parameterized SQL queries
- PostgreSQL parameter placeholders (
$1,$2, ...)
- PostgreSQL parameter placeholders (
- Result field mapping
- Boolean value conversion
- Transaction support
execute()for INSERT, UPDATE, DELETE, and other commandsquery()for multiple rowsqueryOne()for a single rowexecuteScalar()for scalar queries such asCOUNT,MAX, andMINcount()convenience method- Batch SQL execution
- Transactional batch execution
- JSON parameter handling
- Duplicate-key error normalization
Installation
npm install postgres-kitArchitecture
The main abstraction is Executor:
Executor
│
┌─────────┴─────────┐
│ │
DB Transaction
│ │
PoolManager PoolClientManager
│ │
Pool PoolClientExecutordefines common database operations.DBextendsExecutorand provides transaction creation.TransactionextendsExecutorand providescommit()androllback().PoolManagerimplementsDBand manages a PostgreSQLPool.PoolClientManagerimplementsTransactionand manages a PostgreSQLPoolClient.
This keeps application and repository code independent from the details of pg.Pool and pg.PoolClient.
Creating a Pool
import { createPool, PoolManager } from "postgres-kit"
const pool = createPool({
host: "localhost",
port: 5432,
database: "mydb",
user: "postgres",
password: "password",
max: 10,
min: 1,
idleTimeoutMillis: 30000
})
const db = new PoolManager(pool)A connection string can also be provided:
const pool = createPool({
connectionString: process.env.DATABASE_URL
})
const db = new PoolManager(pool)Executor
The Executor interface provides the common database API:
interface Executor {
driver: string
param(i: number): string
execute(sql: string, args?: any[], ctx?: any): Promise<number>
executeBatch(statements: Statement[], firstSuccess?: boolean, ctx?: any): Promise<number>
query<T>(sql: string, args?: any[], m?: StringMap, bools?: Attribute[], ctx?: any): Promise<T[]>
queryOne<T>(sql: string, args?: any[], m?: StringMap, bools?: Attribute[], ctx?: any): Promise<T | null>
executeScalar<T>(sql: string, args?: any[], ctx?: any): Promise<T | null>
count(sql: string, args?: any[], ctx?: any): Promise<number>
}Repositories can therefore depend on Executor instead of directly depending on pg.
SQL Parameters
PostgreSQL uses numbered parameters:
db.param(1) // "$1"
db.param(2) // "$2"
db.param(3) // "$3"Example:
const user = await db.queryOne<User>(
`SELECT id, name, email FROM users WHERE id = $1`,
[userId]
)Using parameters instead of string interpolation keeps values separate from SQL.
Execute
Use execute() for commands where you need the affected row count.
const affected = await db.execute(
`UPDATE users SET name = $1 WHERE id = $2`,
["John", 100]
)The result is the number of affected rows.
For example:
if (affected > 0) {
console.log("User updated")
}Query
Use query() when multiple rows are expected.
interface User {
id: number
name: string
email: string
}
const users = await db.query<User>(
`SELECT id, name, email FROM users ORDER BY id`
)The result is:
User[]Query One
Use queryOne() when only one record is needed.
const user = await db.queryOne<User>(
`SELECT id, name, email FROM users WHERE id = $1`,
[userId]
)The result is:
User | nullIf no record exists, queryOne() returns null.
Result Mapping
There are two important result transformations:
query()
│
▼
handleResults()
├── mapArray()
└── handleBool()Field mapping
StringMap:
export interface StringMap {
[key: string]: string
}allows:
database column → object propertyFor example:
user_id → userId
first_name → firstNamemapArray() performs this transformation.
This is particularly useful if SQL/database naming conventions differ from TypeScript conventions.
Boolean normalization
handleBool() is another useful compatibility feature.
It recognizes:
true
1
t
y
onas true; otherwise it converts the value to false.
It also supports custom true values:
field.trueSo you can conceptually map:
"Y" → true
"N" → falseThis makes sense in a database abstraction layer because different databases and legacy schemas frequently represent boolean values differently.
Example
For example, suppose PostgreSQL returns:
user_id
first_name
last_nameA mapping can be supplied:
const users = await db.query<User>(
"SELECT user_id, first_name, last_name FROM users",
undefined,
{
user_id: "id",
first_name: "firstName",
last_name: "lastName"
}
)The result becomes:
{
id: 1,
firstName: "John",
lastName: "Smith"
}The mapping is applied by mapArray().
Execute Scalar
executeScalar() is intended for queries that return a single scalar value.
It is particularly useful for queries such as:
SELECT COUNT(*)
SELECT MAX(id)
SELECT MIN(id)Example:
const maxId = await db.executeScalar<number>(
"SELECT MAX(id) FROM users"
)Another example:
const total = await db.executeScalar<number>(
"SELECT COUNT(*) FROM users"
)The result type is:
T | nullThe method returns the first column of the first returned row.
Count
For count queries, count() provides a convenient numeric API:
const total = await db.count(
"SELECT COUNT(*) FROM users"
)The result is always a number.
If the scalar result is null, count() returns 0.
const total: number = await db.count(
"SELECT COUNT(*) FROM users"
)Transactions
Transaction creation is straightforward:
pool.connect()
│
▼
BEGIN
│
▼
PoolClientManager
│
├── execute
├── query
├── ...
├── commit
└── rollbackbeginTransaction() obtains a dedicated PoolClient, executes BEGIN, and wraps it in PoolClientManager.
The intended application pattern is therefore something like:
const tx = await db.beginTransaction()
try {
await tx.execute(...)
await tx.execute(...)
await tx.commit()
} catch (e) {
await tx.rollback()
throw e
}Create a transaction with beginTransaction():
const tx = await db.beginTransaction()
try {
await tx.execute(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
[100, sourceAccountId]
)
await tx.execute(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
[100, destinationAccountId]
)
await tx.commit()
} catch (err) {
await tx.rollback()
throw err
}A transaction implements the same Executor interface, so queries and commands can be executed through tx exactly like they are through db.
DB
│
└── beginTransaction()
│
▼
Transaction
│
├── execute()
├── query()
├── queryOne()
├── executeScalar()
├── count()
├── commit()
└── rollback()Batch Execution
There are actually two batch implementations:
executeBatch()
│
└── executeBatchWithClientTx()
executeBatchWithClient()executeBatch() obtains a client and creates a transaction, then executes multiple SQL statements.
Pool
│
└── connect()
│
▼
BEGIN
│
├── statement 1
├── statement 2
├── statement 3
│
▼
COMMITThis is a good design because multiple statements should generally execute atomically when batch semantics imply a transaction
For example:
const statements = [
{
query: "UPDATE users SET active = false WHERE id = $1",
params: [1]
},
{
query: "DELETE FROM sessions WHERE user_id = $1",
params: [1]
}
]
const affectedRows = await db.executeBatch(statements)When multiple statements are supplied through PoolManager, the batch is executed using a transaction.
Conceptually:
connect
↓
BEGIN
↓
execute statements
↓
COMMIT
↓
release clientIf execution fails:
connect
↓
BEGIN
↓
execute
↓
ERROR
↓
ROLLBACK
↓
release clientfirstSuccess
executeBatch() supports an optional firstSuccess flag.
await db.executeBatch(statements, true)When firstSuccess is true, the first statement determines whether the remaining statements are executed.
If the first statement affects at least one row:
statement 1
│
├── rowCount > 0
│
▼
statement 2
↓
statement 3
↓
...If the first statement affects zero rows:
statement 1
│
└── rowCount = 0
↓
stop remaining statementsThe batch still completes its transaction lifecycle.
Executing a Batch on an Existing Client
When a transaction or an existing PoolClient already exists, executeBatchWithClient() can execute statements without creating another transaction.
await executeBatchWithClient(
client,
statements
)This is useful when transaction ownership belongs to the caller.
The distinction is:
executeBatch()
owns connection + transaction lifecycle
executeBatchWithClient()
uses caller's existing clientParameter Normalization
Parameters are normalized before being passed to PostgreSQL.
toArray() handles:
undefined→nullnull→nullDate→ unchanged- objects → object or JSON string depending on configuration
- primitive values → unchanged
Example:
await db.execute(
`
INSERT INTO users(name, metadata)
VALUES ($1, $2)
`,
[
"John",
{
role: "admin"
}
]
)When resource.string is enabled, object parameters are serialized using JSON.stringify().
Field Selection
getFields() can restrict requested fields to an allowed list.
const fields = getFields(
["id", "name", "password"],
["id", "name", "created_at"]
)The resulting fields are:
id
nameThis is useful when building dynamic SQL while restricting fields to a known set.
buildFields() converts the resulting fields into a SQL field list:
buildFields(["id", "name"])returns:
id,nameIf no valid fields are available, it returns:
*Dynamic field names should still come from trusted or validated input because SQL parameters cannot be used for identifiers.
SELECT ${buildFields(fields, allowedFields)}
FROM usersFor example:
SELECT ${buildFields(["id", "name", "password", "status"], ["id", "name", "status"])}
FROM usersreturns:
SELECT id, name, status
FROM usersDuplicate-Key Errors
PostgreSQL reports unique constraint violations using error code 23505.
postgres-kit normalizes this error by adding:
err.error = "duplicate"This allows higher-level repository code to handle duplicate records without depending directly on the PostgreSQL error code.
Example:
try {
await db.execute(
"INSERT INTO users(email) VALUES ($1)",
[email]
)
} catch (err) {
if (err.error === "duplicate") {
// Handle duplicate record
}
throw err
}MinDB
For components that only need basic database operations, MinDB provides a smaller interface:
export interface MinDB {
driver: string
param(i: number): string
execute(sql: string, args?: any[], ctx?: any): Promise<number>
executeBatch(statements: Statement[], firstSuccess?: boolean, ctx?: any): Promise<number>
query<T>(sql: string, args?: any[], m?: StringMap, bools?: Attribute[], ctx?: any): Promise<T[]>
}This can be useful when a component does not need transactions, scalar queries, or queryOne().
Database Metadata
The library defines Attribute metadata that can be used by higher-level repository/data-access components:
interface Attribute {
name?: string
column?: string
type?: DataType
default?: string | number | Date | boolean
key?: boolean
noinsert?: boolean
noupdate?: boolean
version?: boolean
ignored?: boolean
true?: string | number
false?: string | number
}The metadata supports database/property information, field behavior, version fields, and boolean representations.
PostgreSQL Driver
postgres-kit identifies itself as:
db.driver === "postgres"PostgreSQL parameter placeholders are generated using:
db.param(1) // "$1"
db.param(2) // "$2"This keeps the higher-level executor API independent of the exact parameter syntax.
Health Check
Built-in PostgreSQL health checker.
Designed for cloud-native deployments.
Features:
- Connection validation
- Query validation
- Response time measurement
- Configurable timeout
- Kubernetes readiness and liveness probes
Example:
const checker = new PostgreSQLChecker(pool);
const result = await checker.check();Design Philosophy
postgres-kit intentionally stays close to SQL.
It does not attempt to provide:
- entity tracking
- lazy loading
- relationships
- change tracking
- migrations
- query builders
- an ORM-style entity model
Instead, it focuses on providing a small and reusable database execution layer:
Application
│
▼
Repository
│
▼
postgres-kit
│
▼
pg
│
▼
PostgreSQLEcosystem
postgres-kit can work with sql-core and query-mappers. They separate responsibilities into independent layers.
- SQL generation belongs to
sql-core - Object mapping belongs to
query-mappers - PostgreSQL execution belongs to
postgres-kit
This architecture keeps applications lightweight, modular, and easy to maintain.
Application
│
▼
Repository (sql-core)
│
▼
postgres-kit
│
▼
PostgreSQLResponsibilities
| Package | Responsibility |
|----------------------------------------------------------------| --------------------------------------------------------------------- |
| postgres-kit | PostgreSQL execution, repositories, writers, streaming, health checks |
| sql-core | Database-independent repositories, CRUD, SQL builders, transactions |
| query-mappers | Maps database rows to TypeScript models |
License
MIT
