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()
│
▼
pg
In 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.
- admin: SSR Admin Application
- admin-service: Admin Backend Microservice
- sql-simple-modular-sample: RESI API with express and postgres
- 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
npm install postgres-kitThe main abstraction is Executor:
Executor
│
┌─────────┴─────────┐
│ │
DB Transaction
│ │
PoolManager PoolClientManager
│ │
Pool PoolClient
Executordefines 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.
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)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.
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.
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")
}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[]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.
There are two important result transformations:
query()
│
▼
handleResults()
├── mapArray()
└── handleBool()
StringMap:
export interface StringMap {
[key: string]: string
}allows:
database column → object property
For example:
user_id → userId
first_name → firstName
mapArray() performs this transformation.
This is particularly useful if SQL/database naming conventions differ from TypeScript conventions.
handleBool() is another useful compatibility feature.
It recognizes:
true
1
t
y
on
as true; otherwise it converts the value to false.
It also supports custom true values:
field.true
So you can conceptually map:
"Y" → true
"N" → false
This makes sense in a database abstraction layer because different databases and legacy schemas frequently represent boolean values differently.
For example, suppose PostgreSQL returns:
user_id
first_name
last_name
A 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().
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.
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"
)Transaction creation is straightforward:
pool.connect()
│
▼
BEGIN
│
▼
PoolClientManager
│
├── execute
├── query
├── ...
├── commit
└── rollback
beginTransaction() 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()
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
│
▼
COMMIT
This 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 affected = 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 client
If execution fails:
connect
↓
BEGIN
↓
execute
↓
ERROR
↓
ROLLBACK
↓
release client
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 statements
The batch still completes its transaction lifecycle.
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 client
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().
getFields() can restrict requested fields to an allowed list.
const fields = getFields(
["id", "name", "password"],
["id", "name", "created_at"]
)The resulting fields are:
id
name
This 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,name
If 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 usersPostgreSQL 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
}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().
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.
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.
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();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
│
▼
PostgreSQL
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
│
▼
PostgreSQL
| 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 |
MIT