ORM API Guide
This is the complete reference for the @dbsp/core TypeScript API. It covers schema definition, ORM setup, query building, mutations, and observability — with progressive examples from a minimal first query to advanced patterns like set operations, CTEs, and streaming. Use it as your primary reference when building applications with @dbsp/core.
Related guides: NQL Reference | README
Quick Start
Go from zero to your first typed query in 3 steps.
Install
npm install @dbsp/core @dbsp/adapter-pgsql pg
# or
pnpm add @dbsp/core @dbsp/adapter-pgsql pgDefine Your Schema
import { schema, ref } from '@dbsp/core';
const db = schema({
users: {
id: { type: 'uuid', primaryKey: true },
email: { type: 'text', unique: true },
name: 'string',
active: 'boolean',
},
posts: {
id: { type: 'uuid', primaryKey: true },
title: 'string',
content: { type: 'text', nullable: true },
published: 'boolean',
authorId: ref('users'),
},
});Query with Full Type Safety
import { createOrm, schema, ref, eq } from '@dbsp/core';
import { createPgsqlCompileOnlyAdapter } from '@dbsp/adapter-pgsql';
// Schema declares camelCase identifiers (idiomatic JS/TS).
const db = schema({
users: {
id: { type: 'uuid', primaryKey: true },
email: 'string',
active: 'boolean',
},
posts: {
id: { type: 'uuid', primaryKey: true },
title: 'string',
authorId: ref('users'), // camelCase FK in JS
createdAt: 'timestamp',
},
});
// dbCasing: 'snake_case' translates camelCase model identifiers to
// snake_case at the SQL boundary — no runtime column renaming needed.
const adapter = createPgsqlCompileOnlyAdapter({ dbCasing: 'snake_case' });
const orm = createOrm({ schema: db, adapter });
// Type-safe query — code uses camelCase names; SQL uses snake_case columns.
const dump = orm
.select('posts')
.where(eq('authorId', '11111111-1111-1111-1111-111111111111'))
.dump();
// Generated SQL uses snake_case for all translated identifiers:
// SELECT "t0".* FROM "posts" AS "t0" WHERE "t0"."author_id" = $1
// (createdAt → "created_at", users.email → "email", etc.)
console.log(dump.sql);
console.log(dump.params); // ['11111111-1111-1111-1111-111111111111']Type Inference Flow
Types flow automatically from your schema definition through the entire query pipeline:
schema({ users: { id: 'uuid', name: 'string' } })
│
▼
Schema<T> → createOrm({ schema }) → OrmInstance<DB>
│
▼
orm.select('users') → QueryBuilder<{ id: string; name: string }>
│
▼
.all() → Promise<Array<{ id: string; name: string }>>Column types map to TypeScript types:
| Schema Type | TypeScript Type | Notes |
|---|---|---|
'string', 'text', 'uuid' | string | |
'integer', 'decimal' | number | |
'bigint' | bigint | |
'boolean' | boolean | |
'date', 'timestamp' | Date | |
'time' | string | |
'json', 'jsonb' | unknown | |
{ type: T, nullable: true } | T | null |
Without a Database (Compile-Only)
For testing, CLI tooling, or SQL preview — no pg.Pool needed:
import { createOrm, eq } from '@dbsp/core';
import { createPgsqlCompileOnlyAdapter } from '@dbsp/adapter-pgsql';
const adapter = createPgsqlCompileOnlyAdapter();
const orm = createOrm({ schema: db, adapter }); // db from schema() above
const dump = orm.select('users').where(eq('active', true)).dump();
console.log(dump.sql); // SELECT "t0".* FROM "users" AS "t0" WHERE "t0"."active" = $1
console.log(dump.params); // [true]1. Schema Definition
schema() — Define Your Database
import { schema, ref } from '@dbsp/core';
const db = schema({
users: {
id: { type: 'uuid', primaryKey: true },
name: 'string',
email: { type: 'string', unique: true },
bio: { type: 'text', nullable: true },
active: { type: 'boolean', default: 'true' },
createdAt: { type: 'timestamp', default: 'now()' },
},
posts: {
id: { type: 'integer', primaryKey: true, autoIncrement: true },
title: 'string',
content: { type: 'text', nullable: true },
published: { type: 'boolean', default: 'false', index: true },
authorId: ref('users', { onDelete: 'CASCADE', inverse: 'posts' }),
},
});Column Types
| Type | PostgreSQL | TypeScript |
|---|---|---|
'string' | VARCHAR | string |
'text' | TEXT | string |
'integer' | INTEGER | number |
'bigint' | BIGINT | bigint |
'decimal' | DECIMAL | number |
'boolean' | BOOLEAN | boolean |
'date' | DATE | Date |
'time' | TIME | string |
'timestamp' | TIMESTAMP WITH TIME ZONE | Date |
'json' | JSON | unknown |
'jsonb' | JSONB | unknown |
'uuid' | UUID | string |
'daterange' | DATERANGE | [Date, Date] |
'tstzrange' | TSTZRANGE | [Date, Date] |
'int4range' | INT4RANGE | [number, number] |
Column Options
Shorthand (type only) or object with options:
// doctest: skip — illustrative data/type literal fragment (not executable code)
{
name: 'string', // shorthand
email: { type: 'string', unique: true }, // with options
bio: { type: 'text', nullable: true }, // nullable
id: { type: 'integer', primaryKey: true, autoIncrement: true },
status: { type: 'string', default: 'active', index: true },
}| Option | Type | Description |
|---|---|---|
type | ColumnType | Column data type (required in object form) |
primaryKey | boolean | Mark as primary key |
autoIncrement | boolean | Auto-increment (serial) |
nullable | boolean | Allow NULL values |
unique | boolean | Unique constraint |
index | boolean | Create index |
default | string | Default value expression |
Relations with ref()
// doctest: skip — API signature reference (TypeScript function signature, not executable code)
ref(targetTable: string, options?: RefOptions)Relations are auto-inferred from ref() calls. The planner detects:
- belongsTo (N:1) — the table with the FK
- hasMany (1:N) — the target table
- M:N — via junction table with two FKs
const db = schema({
users: { id: 'uuid', name: 'string', email: 'string', active: 'boolean', createdAt: 'timestamp' },
categories: { id: 'uuid', name: 'string' },
posts: {
authorId: ref('users', { as: 'author', inverse: 'authoredPosts' }), // basic FK
editorId: ref('users', { as: 'editor', inverse: 'editedPosts', nullable: true }), // optional relation
categoryId: ref('categories', {
onDelete: 'CASCADE',
as: 'category', // local relation name
inverse: 'posts', // reverse relation name on target
}),
},
});ref() Options
| Option | Type | Description |
|---|---|---|
nullable | boolean | Optional relation (LEFT JOIN) |
unique | boolean | Makes it 1:1 instead of 1:N |
onDelete | 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION' | Delete action |
onUpdate | 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION' | Update action |
as | string | Local relation name override |
inverse | string | Reverse relation name on target table |
roles | SelfRefRoles | Role names for self-referential relations |
columns | string[] | Source columns (composite FK) |
references | string[] | Target columns (defaults to PK) |
Self-Referential Relations
For trees/hierarchies, use roles to name the traversal paths:
const db = schema({
employees: {
id: { type: 'integer', primaryKey: true, autoIncrement: true },
name: 'string',
managerId: ref('employees', {
nullable: true,
roles: {
parent: 'manager', // direct parent
children: 'directReports', // direct children
ancestors: 'managementChain', // recursive upward (CTE)
descendants: 'allReports', // recursive downward (CTE)
},
}),
},
});Many-to-Many (Junction Tables)
const db = schema({
posts: { id: { type: 'integer', primaryKey: true, autoIncrement: true }, title: 'string' },
tags: { id: { type: 'integer', primaryKey: true, autoIncrement: true }, name: 'string' },
postTags: {
postId: ref('posts', { onDelete: 'CASCADE' }),
tagId: ref('tags', { onDelete: 'CASCADE' }),
},
});Schema Options (dbCasing)
Control how column names map between JS and database:
const db = schema({
users: {
firstName: 'string', // JS: camelCase
lastName: 'string',
},
}, undefined, { dbCasing: 'snake_case' });
// DB columns: first_name, last_name
// JS properties: firstName, lastName| Value | DB Columns | JS Properties | Transform |
|---|---|---|---|
'snake_case' | first_name | firstName | Auto camelCase <-> snake_case |
'camelCase' | firstName | firstName | No transform |
'preserve' | as-is | as-is | No transform |
Schema Options (SchemaOptions)
The third argument to schema() accepts a SchemaOptions object that controls schema-wide DSL interpretation:
// doctest: skip — signature reference
schema(definition, constraints?, options?, extras?)| Option | Type | Default | Description |
|---|---|---|---|
defaultFilters | Record<string, WhereIntent> | — | Filters applied automatically to all queries per table. Override with .withoutDefaultFilters() on the query builder. |
defaultPkColumnName | string | null | 'id' | Column name treated as the implicit primary key for short-form columns. Set to null to disable the convention; pass a custom string for project-specific naming schemes. Empty or whitespace-only strings are rejected eagerly at schema() time. |
Example: implicit-PK convention
// Default: 'id' is treated as PK when no explicit primaryKey: true is set
const db = schema({
users: { id: 'uuid' }, // 'id' becomes PK via convention
});
// Custom convention: 'pk_uuid' is the implicit PK name
const db2 = schema(
{ users: { pk_uuid: 'uuid' } },
undefined,
{ defaultPkColumnName: 'pk_uuid' }
);
// Strict mode: no implicit PK convention — every PK must be explicit
const db3 = schema(
{ users: { id: { type: 'uuid', primaryKey: true } } },
undefined,
{ defaultPkColumnName: null }
);Resolution order
When inferring the primary key for a table, schema() checks in this order:
- Explicit
primaryKey: trueon a column - Column matching
defaultPkColumnName(the implicit convention) — skipped when set tonull - FK columns (composite, for junction tables — applies regardless of
defaultPkColumnName) - No primary key
Schema Constraints
Add composite indexes and foreign keys via the constraints parameter:
const db = schema(
{
orders: { id: 'uuid', total: 'integer' },
products: { id: 'uuid', name: 'string' },
orderItems: { orderId: ref('orders'), productId: ref('products'), quantity: 'integer' },
},
{ orderItems: { indexes: [{ columns: ['orderId', 'productId'], unique: true }] } }
);2. Creating the ORM
createOrm() — ORM Instance
import { createOrm } from '@dbsp/core';
import { createPgsqlAdapter } from '@dbsp/adapter-pgsql';
import { Pool } from 'pg';
const orm = createOrm({
schema: db,
adapter: createPgsqlAdapter(new Pool({ connectionString: process.env.DATABASE_URL })),
});Options
| Option | Type | Default | Description |
|---|---|---|---|
schema | Schema | - | From schema() (preferred) |
model | ModelIR | - | Direct ModelIR (alternative to schema) |
adapter | Adapter | - | Database adapter (optional for compile-only) |
strictMode | boolean | false | Throw on ambiguous relations |
maxDepth | number | 10 | Max recursive depth |
maxTableHops | number | 5 | Max relation hops |
Compile-Only Mode (No Database)
For tooling, testing, or SQL preview without a database connection:
import { createPgsqlCompileOnlyAdapter } from '@dbsp/adapter-pgsql';
const orm = createOrm({
schema: db,
adapter: createPgsqlCompileOnlyAdapter(),
});
// dump() works — returns SQL + params
const { sql, params } = orm.select('users').where(eq('active', true)).dump();
// execute() throws ExecutionErrorSchema Scoping (Multi-Tenant)
const tenantOrm = orm.withSchema('tenant_123');
const users = await tenantOrm.select('users').dump();
// SQL: SELECT * FROM "tenant_123"."users"Transactions
// doctest: skip — exec-only operation; uses orders/orderItems tables not in default preamble schema
const result = await orm.transaction(async (tx) => {
await tx.insert('orders').values({ customerId: 1, total: 99 }).dump();
await tx.insert('orderItems').values({ orderId: 1, productId: 5 }).dump();
return tx.select('orders').where(eq('customerId', 1)).dump();
});
// Auto-commit on success, auto-rollback on errorThe callback receives a transaction-scoped ORM instance (tx) with the full ORM API. The transaction result is the return value of your callback:
// doctest: skip — exec-only operation; uses orders table not in default preamble schema
// Typed return value
const order = await orm.transaction(async (tx) => {
const [created] = await tx.insert('orders')
.values({ customerId: 1, total: 99 })
.returning(['id', 'total'])
.dump();
return created; // { id: 42, total: 99 }
});
console.log(order.id); // 42
// Schema-scoped transactions (multi-tenant)
orm.withSchema('tenant_42').transaction(async (tx) => {
// tx is scoped to 'tenant_42' schema
await tx.insert('events').values({ type: 'signup' }).dump();
});Nested transactions reuse the outer transaction context — no savepoints, no additional BEGIN/COMMIT:
// doctest: skip — exec-only operation; uses logs table not in default preamble schema
orm.transaction(async (outer) => {
await outer.insert('users').values({ name: 'Alice' }).dump();
await outer.transaction(async (inner) => {
// inner reuses the same connection and transaction
await inner.insert('logs').values({ action: 'user_created' }).dump();
});
});| Behavior | Detail |
|---|---|
| Success | COMMIT after callback returns |
| Error thrown | ROLLBACK, error re-thrown |
| Nested call | Reuses parent transaction (no savepoints) |
| Connection | Dedicated client, released on completion |
3. Querying (QueryBuilder)
All query builder methods return a new immutable instance. Safe to branch and reuse.
select() — Start a Query
const users = await orm.select('users').dump();columns() — Select Specific Columns
const names = await orm.select('users').columns(['id', 'name']).dump();
// SQL: SELECT "id", "name" FROM "users"distinct() — Remove Duplicates
// doctest: real-db-only — requires a live PostgreSQL connection
const distinctNames = await orm.select('users').columns(['name']).distinct().all();
// SQL: SELECT DISTINCT "name" FROM "users"where() — Filter Rows
Uses filter helpers (see full list below):
import { eq, gt, and, or, not, like, inArray, isNull, isNotNull } from '@dbsp/core';
// Simple equality
orm.select('users').where(eq('active', true))
// Multiple conditions
orm.select('users').where(and(
eq('active', true),
gt('age', 18),
like('email', '%@example.com')
))
// OR conditions
orm.select('users').where(or(
eq('role', 'admin'),
eq('role', 'superadmin')
))
// NOT
orm.select('users').where(not(eq('deleted', true)))
// IN array
orm.select('users').where(inArray('status', ['active', 'pending']))
// NULL checks
orm.select('users').where(isNull('deletedAt'))
orm.select('users').where(isNotNull('email'))Filter Helpers — Complete List
| Helper | SQL | Example |
|---|---|---|
eq(field, value) | = | eq('status', 'active') |
neq(field, value) | != | neq('role', 'guest') |
gt(field, value) | > | gt('age', 18) |
gte(field, value) | >= | gte('price', 100) |
lt(field, value) | < | lt('stock', 10) |
lte(field, value) | <= | lte('rating', 5) |
like(field, pattern) | LIKE | like('name', 'A%') |
inArray(field, values) | IN | inArray('id', [1, 2, 3]) |
isNull(field) | IS NULL | isNull('deletedAt') |
isNotNull(field) | IS NOT NULL | isNotNull('email') |
isDistinctFrom(field, value) | IS DISTINCT FROM | isDistinctFrom('status', 'active') |
and(...conditions) | AND | and(eq('a', 1), gt('b', 2)) |
or(...conditions) | OR | or(eq('x', 1), eq('x', 2)) |
not(condition) | NOT | not(eq('deleted', true)) |
isDistinctFrom() — Null-safe Inequality
Unlike neq(), returns true when one side is NULL and the other is not. Standard SQL (SQL:2003).
// doctest: skip — block uses an isDistinctFrom WHERE handler not registered in the doctest adapter (compile-time, not exec-time)
import { isDistinctFrom } from '@dbsp/core';
// Find rows where status changed (NULL-safe)
const changed = await orm.select('users')
.where(isDistinctFrom('status', 'active'))
.dump();
// SQL: WHERE status IS DISTINCT FROM $1Relation Filters
Filter by related records without loading them:
import { Pool } from 'pg';
import { schema, ref, createOrm, eq, exists, notExists, some, every, none } from '@dbsp/core';
import { createPgsqlAdapter } from '@dbsp/adapter-pgsql';
const __db = schema({
users: { id: 'integer', name: 'string' },
posts: { id: 'integer', userId: ref('users'), published: 'boolean' },
} as const);
const __pool = new Pool({ connectionString: process.env.DATABASE_URL });
const __orm = createOrm({ schema: __db, adapter: createPgsqlAdapter(__pool) });
// Users who have at least one post
__orm.select('users').where(exists('posts'))
// Users who have no posts
__orm.select('users').where(notExists('posts'))
// Users who have at least one published post — some() requires a RelationRef
__orm.select('users').where(some(__db.tables.users.posts, (p) => eq(p.published, true)))
// Users where ALL posts are published
__orm.select('users').where(every(__db.tables.users.posts, (p) => eq(p.published, true)))
// Users where NO post is a draft (false = not draft)
__orm.select('users').where(none(__db.tables.users.posts, (p) => eq(p.published, false)))Range Operators (PostgreSQL)
import { schema, createOrm, rangeOverlaps, rangeContains, rangeContainedBy } from '@dbsp/core';
import { createPgsqlCompileOnlyAdapter } from '@dbsp/adapter-pgsql';
const __rangeDb = schema({
bookings: { id: 'integer', period: 'daterange' },
events: { id: 'integer', dateRange: 'daterange' },
} as const);
const __rangeOrm = createOrm({ schema: __rangeDb, adapter: createPgsqlCompileOnlyAdapter() });
// Bookings that overlap a date range
__rangeOrm.select('bookings').where(rangeOverlaps('period', ['2024-01-01', '2024-01-31'])).dump();
// Events that contain a specific date
__rangeOrm.select('events').where(rangeContains('dateRange', ['2024-06-15', '2024-06-15'])).dump();
// Events within a year
__rangeOrm.select('events').where(rangeContainedBy('dateRange', ['2024-01-01', '2024-12-31'])).dump();orderBy() — Sort Results
// Single field (ascending by default)
orm.select('users').orderBy('name')
// With direction
orm.select('users').orderBy('createdAt', 'desc')
// Multiple fields (object syntax)
orm.select('users').orderBy({ createdAt: 'desc', name: 'asc' })
// Advanced: nulls positioning
orm.select('users').orderBy([
{ column: 'createdAt', direction: 'desc', nulls: 'last' }
])limit() + offset() — Pagination Primitives
orm.select('posts').orderBy('createdAt', 'desc').limit(10).offset(20)Aggregates
// doctest: skip — illustrative fragment (distinct() helper not in doctest preamble; uses orders/products tables not in default preamble)
import { distinct } from '@dbsp/core';
// COUNT
orm.select('users').count()
orm.select('orders').count('id', 'totalOrders')
orm.select('orders').count(distinct('customerId'), 'uniqueCustomers')
// SUM, AVG, MIN, MAX
orm.select('orders').sum('amount', 'totalRevenue')
orm.select('orders').avg('amount', 'averageOrder')
orm.select('products').min('price', 'cheapest')
orm.select('products').max('price', 'mostExpensive')groupBy() + having()
orm.select('posts')
.groupBy(['published'])
.count('id', 'postCount')
.having(gt('postCount', 10))
.dump()
// SQL: SELECT "published", COUNT("id") AS "postCount" FROM "posts"
// GROUP BY "published" HAVING "postCount" > $1Window Functions
// doctest: skip — illustrative fragment (window function helpers rowNumber/rank/denseRank/wSum/wAvg/wCount/wMin/wMax/lag/lead not in doctest preamble)
import { rowNumber, rank, denseRank, wSum, wAvg, wCount, wMin, wMax, lag, lead } from '@dbsp/core';
// Row numbering
orm.select('posts').columns([
'title', 'authorId',
rowNumber().orderBy('createdAt', 'desc').as('rn')
])
// Ranking within partitions
orm.select('products').columns([
'name', 'category', 'price',
rank().partitionBy('category').orderBy('price').as('priceRank')
])
// Dense rank (no gaps)
orm.select('employees').columns([
'name', 'department', 'salary',
denseRank().partitionBy('department').orderBy('salary', 'desc').as('salaryRank')
])
// Running total
orm.select('orders').columns([
'date', 'amount',
wSum('amount').orderBy('date').as('runningTotal')
])
// Previous/next row values
orm.select('prices').columns([
'date', 'price',
lag('price').orderBy('date').as('prevPrice'),
lead('price').orderBy('date').as('nextPrice')
])
// Aggregate windows
orm.select('sales').columns([
'customerId', 'amount',
wAvg('amount').partitionBy('customerId').as('avgPerCustomer'),
wCount('id').partitionBy('customerId').as('ordersPerCustomer'),
wMin('amount').partitionBy('customerId').as('minOrder'),
wMax('amount').partitionBy('customerId').as('maxOrder')
])Expressions
// doctest: skip — illustrative fragment (coalesce/raw/col/relationColumn helpers not in doctest preamble)
import { coalesce, raw, col, relationColumn } from '@dbsp/core';
// COALESCE — first non-null value
orm.select('users').columns([
'id',
coalesce(['nickname', 'name'], 'displayName')
])
// SQL: SELECT "id", COALESCE("nickname", "name") AS "displayName"
// raw() — SQL escape hatch
orm.select('users').columns([
'id',
raw('EXTRACT(YEAR FROM "created_at")', 'joinYear')
])
// col() — aliased column
orm.select('users').columns([col('firstName', 'first')])
// relationColumn() — column from joined relation
orm.select('posts').columns([
'title',
relationColumn('author', 'name', 'authorName')
])Subqueries
// doctest: skip — exec-only operation; requires a real PostgreSQL connection (products table not in default preamble schema)
import { subquery, outerRef } from '@dbsp/core';
// Correlated subquery: products with above-average price in their category
orm.select('products')
.where(gt('price',
subquery('products')
.select('avgPrice')
.where(eq('categoryId', outerRef('categoryId')))
.avg('price')
))
.all()inSubquery() — WHERE IN (Subquery)
Filter rows where a column value exists in the result of a subquery.
import { inSubquery, subquery, eq } from '@dbsp/core';
// Users who have published posts
const authors = await orm.select('users')
.where(inSubquery('id', subquery('posts').select('authorId').where(eq('published', true))))
.dump();
// SQL: WHERE id = ANY(SELECT author_id FROM posts WHERE published = $1)Scalar Subquery in SELECT — .asExpr()
Use a subquery as a computed column in SELECT.
// doctest: skip — exec-only (uses `await orm.select(...).all()`); see compile-only `.asExpr()` example near the bottom of this file for a runnable variant
import { subquery, eq } from '@dbsp/core';
// Count posts per user as a computed column
const users = await orm.select('users')
.columns([
'id',
'name',
subquery('posts').count().where(eq('authorId', outerRef('id'))).asExpr('post_count')
])
.all();
// SQL: SELECT id, name, (SELECT count(*) FROM posts WHERE author_id = users.id) AS post_count FROM usersSet Operations
Combine query results with UNION, INTERSECT, or EXCEPT. All variants support the All suffix (e.g., .unionAll()) to preserve duplicates.
const q1 = orm.select('users').where(eq('role', 'admin'));
const q2 = orm.select('users').where(eq('role', 'moderator'));
const q3 = orm.select('users').where(eq('active', true));
// UNION (deduplicated)
const staff = q1.union(q2).dump();
// UNION ALL (with duplicates)
const allStaff = q1.unionAll(q2).dump();
// INTERSECT
const both = q1.intersect(q2).dump();
// EXCEPT
const adminsOnly = q1.except(q2).dump();
// Chaining
const result = q1.union(q2).except(q3).dump();
// Dump for SQL preview
const dump = q1.union(q2).dump();
console.log(dump.sql); // (SELECT ...) UNION (SELECT ...)Returns a SetOperationBuilder with .all(), .first(), and .dump() methods.
4. Includes (Eager Loading)
Simple Include
const usersWithPosts = await orm.select('users').include('posts').dump();
// [{ id: 1, name: 'Alice', posts: [{ id: 1, title: '...' }, ...] }]Dot Notation (Deep Nesting)
orm.select('users').include('posts.comments')
orm.select('users').include('posts.comments.author')Multiple Includes
orm.select('users')
.include('posts')
.include('profile')
.include('posts.comments')
.dump()Include Options
orm.select('users').include('posts', {
where: eq('published', true), // filter related records
select: { type: 'fields', fields: ['title', 'slug'] }, // select specific columns
via: 'authoredPosts', // disambiguate relation
})| Option | Type | Description |
|---|---|---|
where | WhereIntent | Filter conditions on related records |
select | SelectSpec | Select specific columns |
via | string | Disambiguate multiple relations to same table |
recursive | boolean | Enable recursive CTE traversal |
direction | 'ancestors' | 'descendants' | Traversal direction (required when recursive) |
flat | boolean | Flat array output with depth field |
maxDepth | number | Maximum traversal depth (default: 100) |
Recursive Includes (Hierarchies)
// Ancestors (up the tree)
orm.select('categories')
.where(eq('id', 5))
.include('parent', { recursive: true, direction: 'ancestors' })
.dump()
// Descendants (down the tree) — flat output
orm.select('categories')
.where(eq('id', 1))
.include('children', {
recursive: true,
direction: 'descendants',
flat: true,
maxDepth: 10
})
.dump()5. Mutations
Insert
// Basic insert
orm.insert('users')
.values({ name: 'Alice', email: 'alice@example.com' })
.dump();
// Bulk insert
orm.insert('users')
.values([
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' },
])
.dump();
// With RETURNING
const { sql: newUserSql } = orm.insert('users')
.values({ name: 'Alice', email: 'alice@example.com' })
.returning(['id', 'name', 'createdAt'])
.dump();Update
// Update with WHERE (required)
orm.update('users')
.set({ name: 'Alice Smith' })
.where(eq('id', 1))
.dump();
// Update all rows (explicit intent)
orm.updateAll('users')
.set({ active: false })
.dump();
// With RETURNING
const updated = await orm.update('users')
.set({ active: true })
.where(eq('email', 'alice@example.com'))
.returning(['id', 'name', 'active'])
.dump();Delete
// Delete with WHERE (required)
orm.delete('users')
.where(eq('id', 1))
.dump();
// Delete all rows (explicit intent)
orm.deleteAll('users').dump();
// With cascade
orm.delete('users')
.where(eq('id', 1))
.cascade() // cascade to all relations
.dump();
// With RETURNING
const deleted = await orm.delete('posts')
.where(eq('published', false))
.returning(['id', 'title'])
.dump();Upsert (Insert or Update on Conflict)
// On conflict by columns — auto-update non-conflict fields
orm.upsert('users')
.values({ name: 'Alice', email: 'alice@example.com', active: true })
.onConflict(['email'])
.doUpdate()
.dump();
// On conflict — update specific fields
orm.upsert('users')
.values({ name: 'Alice', email: 'alice@example.com', active: true })
.onConflict(['email'])
.doUpdate({ name: 'Alice Updated', active: true })
.dump();
// On conflict by constraint name
orm.upsert('users')
.values({ name: 'Alice', email: 'alice@example.com' })
.onConflictConstraint('users_email_unique')
.doNothing()
.dump();
// With RETURNING
const result = await orm.upsert('users')
.values({ name: 'Alice', email: 'alice@example.com' })
.onConflict(['email'])
.doUpdate()
.returning(['id', 'name'])
.dump();Mutation Observability
All mutation builders support dump():
const { sql, params } = orm.insert('users')
.values({ name: 'Alice', email: 'alice@example.com' })
.dump();
console.log(sql); // INSERT INTO "users" ("name", "email") VALUES ($1, $2)
console.log(params); // ['Alice', 'alice@example.com']6. Execution
Result Methods
| Method | Returns | Description |
|---|---|---|
all() | Promise<T[]> | All matching rows |
execute() | Promise<T[]> | Alias for all() |
first() | Promise<T | undefined> | First row or undefined |
firstOrThrow() | Promise<T> | First row or throws NotFoundError |
byId(value) | Promise<T | undefined> | Find by primary key |
byIdOrThrow(value) | Promise<T> | Find by PK or throws NotFoundError |
byIds(values) | Promise<T[]> | Find multiple by PKs |
exists() | Promise<boolean> | Check if any rows match (optimized) |
// Standard execution
const dump1 = orm.select('users').dump();
const dump2 = orm.select('users').where(eq('id', 1)).dump();
// Primary key shortcuts — these require real DB connection
// const user = await orm.select('users').byId(1);
// const user = await orm.select('users').byIdOrThrow(1);
// const users = await orm.select('users').byIds([1, 2, 3]);Existence Check
The exists() method provides an optimized way to check if any rows match a query. Unlike first() !== undefined, it generates efficient SELECT EXISTS(...) SQL:
// doctest: real-db-only — requires a live PostgreSQL connection
// Check if any active users exist
const hasActiveUsers = await orm.select('users').where(eq('active', true)).exists();
// More efficient than:
// const hasActiveUsers = (await orm.select('users').where(eq('active', true)).dump()) !== undefined;Generated SQL:
SELECT EXISTS(SELECT 1 FROM users WHERE users.active = $1) AS "exists"Key behaviors:
- Returns
trueif at least one row matches,falseotherwise - Strips
orderBy(irrelevant for existence) - Strips
include(related data not needed) - Preserves
where,groupBy,having,offset - Sets internal
limit: 1for optimization
Debugging: Use existsDump() to inspect the generated intent without executing:
const dump = orm.select('users').where(eq('active', true)).existsDump();
console.log(dump.sql); // SELECT EXISTS(...) AS "exists"
console.log(dump.params); // [true]
console.log(dump.plan); // PlanReport with existsWrap: trueStreaming
// doctest: real-db-only — requires a live PostgreSQL connection
const stream = orm.select('users').stream();
let shouldStop = false; // set to true to stop early (e.g. on timeout or signal)
for await (const user of stream) {
console.log(user.name);
if (shouldStop) break; // early break releases connection
}Stream Options
// doctest: real-db-only — requires a live PostgreSQL connection
const stream = orm.select('users').stream({
chunkSize: 100, // rows fetched per cursor batch (default: framework-defined)
onStart(dump) {
// Called once on first next() — lazy initialization
console.log('SQL:', dump.sql);
console.log('Params:', dump.params);
console.log('Plan:', dump.plan);
},
});
for await (const user of stream) {
process.stdout.write(`${user.name}\n`);
}| Option | Type | Description |
|---|---|---|
chunkSize | number | Rows per cursor fetch batch |
onStart | (dump: Dump) => void | Callback invoked once before first row; receives full query dump (SQL, params, plan) |
Pagination
Offset-Based
// doctest: real-db-only — requires a live PostgreSQL connection
const page = await orm.select('users')
.orderBy('name')
.paginate({ page: 2, perPage: 25 });
// page.data — User[]
// page.pagination.page — 2
// page.pagination.perPage — 25
// page.pagination.total — 150
// page.pagination.totalPages — 6
// page.pagination.hasNextPage — true
// page.pagination.hasPrevPage — trueCursor-Based
// doctest: real-db-only — requires a live PostgreSQL connection
const page = await orm.select('users')
.orderBy('createdAt', 'desc')
.cursorPaginate({ limit: 25 });
// page.data — User[]
// page.nextCursor — 'eyJ...' (opaque cursor string)
// page.hasNextPage — true
// Next page:
const next = await orm.select('users')
.orderBy('createdAt', 'desc')
.cursorPaginate({ cursor: page.nextCursor, limit: 25 });Observability
// Execution plan (no database call)
const plan = orm.select('users').include('posts').plan();
console.log(plan.decisions); // [{ type: 'include-strategy', choice: 'json_agg', ... }]
console.log(plan.warnings); // [{ type: 'performance', message: '...' }]
// Full dump: plan + SQL + params
const dump = orm.select('users').where(eq('active', true)).dump();
console.log(dump.sql); // SELECT * FROM "users" WHERE "active" = $1
console.log(dump.params); // [true]
console.log(dump.plan); // PlanReport
console.log(dump.meta); // { schema?: string, queryName?: string }7. Error Handling
All errors have a code property for programmatic handling and a name property for type checking.
| Error | Code | When |
|---|---|---|
ExecutionError | DBSP_E001 | Executing without adapter configured |
NotFoundError | DBSP_E002 | firstOrThrow() / byIdOrThrow() finds nothing |
AmbiguousRelationError | DBSP_E003 | Strict mode + ambiguous relation |
RelationNotFoundError | DBSP_E004 | Requested relation doesn't exist |
InvalidOperationError | DBSP_E005 | Malformed operation |
UnsafeOperationError | DBSP_E006 | update()/delete() without WHERE |
TableNotFoundError | DBSP_E007 | Table not in schema |
ColumnNotFoundError | DBSP_E008 | Column not on table |
// doctest: skip — illustrative fragment (Errors namespace not in doctest preamble; block also uses .firstOrThrow() which requires a real PostgreSQL connection)
import { Errors } from '@dbsp/core';
try {
await orm.select('users').firstOrThrow();
} catch (error) {
if (Errors.isNotFound(error)) {
console.log(`Table: ${error.table}`); // 'users'
}
if (Errors.isTableNotFound(error)) {
console.log(`Available: ${error.available}`);
console.log(`Did you mean: ${error.suggestion}`);
}
}Strict Mode
Strict mode throws AmbiguousRelationError when a relation is ambiguous (e.g., two FKs to the same table):
const orm = createOrm({ schema: db, adapter, strictMode: true });
// Throws AmbiguousRelationError — use withRelationHint() to disambiguate
orm.select('posts').include('users')
// Fix: specify which relation
orm.select('posts').include('users', { via: 'author' })
// Or per-query strict mode:
orm.select('posts').withStrictMode(false).include('users')8. Advanced
NQL Template Literals
Use the pipe-based Natural Query Language directly from TypeScript:
const users = await orm.nql<User[]>`users | where active = true | limit 10`.dump();
const dump = orm.nql`posts | where published = true | select title, author.*`.dump();See the NQL Reference for full syntax.
Hierarchy Shortcuts
// doctest: skip — exec-only operation; listAncestors/listDescendants require a real PostgreSQL connection and an employees table not in default preamble
// List all ancestors of node (flat array)
const ancestors = await orm.listAncestors('employees', 42, {
parentId: 'managerId',
nodeId: 'id',
maxDepth: 10,
});
// List all descendants of node (flat array)
const descendants = await orm.listDescendants('employees', 1, {
parentId: 'managerId',
nodeId: 'id',
});Query Configuration
// Override strict mode per query
orm.select('posts').withStrictMode(true)
// Disambiguate relation
orm.select('posts').withRelationHint('users', 'author')
// Override plan options
orm.select('users').withPlanOptions({ preferredStrategy: 'json_agg' })Raw SQL (Escape Hatch)
// doctest: real-db-only — requires a live PostgreSQL connection
const results = await orm.raw<{ count: number }>(
'SELECT COUNT(*) as count FROM "users" WHERE "active" = $1',
[true]
);Warning:
raw()bypasses the planner and type safety. Use only when the ORM API is insufficient.
PostgreSQL Built-in Helpers
Available from @dbsp/adapter-pgsql:
import { generateSeries, nextval } from '@dbsp/adapter-pgsql';
// Generate a series of values
generateSeries(1, 100) // generate_series(1, 100)
generateSeries(0, 50, 5) // generate_series(0, 50, 5) — with step
// Get next sequence value
nextval('order_id_seq') // nextval('order_id_seq')Automatic Parameter Type Casting
When the adapter has access to ModelIR column types (via originalDbType from introspection), it automatically adds explicit type casts to query parameters:
-- Without casting (ambiguous for nullable columns)
WHERE enclosing_symbol_id = $1
-- With casting (explicit, no type inference errors)
WHERE enclosing_symbol_id = CAST($1 AS integer)This is transparent — no code changes needed. The adapter resolves types from the schema and applies casts where needed.
9. Naming Conventions
The adapter automatically derives foreign key column names from table names using a singularize(tableName) + "_" + pkColumn convention. For example, the posts table with PK id produces FK column post_id.
Both the primary key convention and the FK derivation function are configurable.
Adapter Options
import { createPgsqlAdapter } from '@dbsp/adapter-pgsql';
const adapter = createPgsqlAdapter(pool, {
// Default PK column name (default: 'id')
defaultPkColumnName: 'uuid',
// Custom FK derivation (default: singularize(table) + '_' + pk)
deriveFkColumnName: (tableName, pkColumnName) =>
`fk_${singularize(tableName)}_${pkColumnName}`,
});| Option | Type | Default | Description |
|---|---|---|---|
defaultPkColumnName | string | 'id' | Convention fallback when schema metadata doesn't provide an explicit PK column |
deriveFkColumnName | (table: string, pk: string) => string | singularize(table)_pk | Derives FK column names from the referenced table and its PK |
NamingPlugin — Column Name Transformation
When your database uses snake_case columns but your TypeScript models use camelCase, the NamingPlugin handles bidirectional transformation automatically:
import { createPgsqlAdapter } from '@dbsp/adapter-pgsql';
// Built-in: CamelCaseNamingPlugin (camelCase ↔ snake_case)
const adapter = createPgsqlAdapter(pool, {
dbCasing: 'snake_case', // enables CamelCaseNamingPlugin
});
// Query results: snake_case DB columns → camelCase JS properties
// Query compilation: camelCase JS properties → snake_case SQL columnsTwo built-in plugins:
| Plugin | dbCasing | Effect |
|---|---|---|
IdentityNamingPlugin | 'preserve' (default) | No transformation — columns pass through unchanged |
CamelCaseNamingPlugin | 'snake_case' | createdAt ↔ created_at, userProfileImage ↔ user_profile_image |
The CamelCaseNamingPlugin handles edge cases: acronyms (parseJSON → parse_json), numbers (field1Name → field1_name), leading underscores (_privateField → _private_field).
singularize() and pluralize() — Importable Helpers
These are optional utility functions, exported from @dbsp/core, useful for building custom FK derivation or other naming logic:
// doctest: skip — illustrative fragment (singularize/pluralize/IRREGULAR_PLURALS not in doctest preamble)
import { singularize, pluralize, IRREGULAR_PLURALS } from '@dbsp/core';
singularize('posts'); // → 'post'
singularize('categories'); // → 'category'
singularize('people'); // → 'person' (built-in irregular)
pluralize('post'); // → 'posts'
pluralize('category'); // → 'categories'
pluralize('person'); // → 'people' (built-in irregular)Custom Overrides
Pass a Record<string, string> to singularize() for domain-specific plurals not covered by the built-in rules:
// doctest: skip — illustrative fragment (singularize/pluralize/IRREGULAR_PLURALS not in doctest preamble)
const domainOverrides = {
matrices: 'matrix',
alumni: 'alumnus',
indices: 'index',
};
singularize('matrices', domainOverrides); // → 'matrix'
singularize('users', domainOverrides); // → 'user' (falls through to built-in rules)Overrides take priority over built-in irregular plurals:
// doctest: skip — illustrative fragment (singularize/pluralize/IRREGULAR_PLURALS not in doctest preamble)
singularize('people', { people: 'individual' }); // → 'individual' (overrides built-in 'person')IRREGULAR_PLURALS — Built-in Map
The IRREGULAR_PLURALS constant is exported for inspection or extension:
import { IRREGULAR_PLURALS } from '@dbsp/core';
// Built-in: people→person, children→child, men→man, women→woman,
// teeth→tooth, feet→foot, geese→goose, mice→mouse,
// data→datum, media→medium, criteria→criterion, phenomena→phenomenonPutting It Together
A fully custom FK naming strategy using a Map of overrides:
import { createPgsqlAdapter } from '@dbsp/adapter-pgsql';
import { singularize } from '@dbsp/core';
const myPlurals: Record<string, string> = {
matrices: 'matrix',
alumni: 'alumnus',
};
const adapter = createPgsqlAdapter(pool, {
deriveFkColumnName: (tableName, pkColumnName) =>
`${singularize(tableName, myPlurals)}_${pkColumnName}`,
});10. Adapter Logging
The adapter accepts an optional AdapterLogger for query observability. All methods are optional — implement only what you need:
import { createPgsqlAdapter } from '@dbsp/adapter-pgsql';
const adapter = createPgsqlAdapter(pool, {
logger: {
debug(message, ...args) {
console.debug(`[dbsp] ${message}`, ...args);
},
warn(message, ...args) {
console.warn(`[dbsp] ${message}`, ...args);
},
error(message, ...args) {
console.error(`[dbsp] ${message}`, ...args);
},
},
});| Method | When called |
|---|---|
debug?(message, ...args) | Query compilation details, plan decisions |
warn?(message, ...args) | Potential performance issues, deprecations |
error?(message, ...args) | Query failures, connection errors |
The AdapterLogger interface is exported from @dbsp/core — use any logger that matches the shape (Winston, Pino, console, custom).
Note: The PostgreSQL adapter currently logs sparingly (cleanup errors during streaming transactions). The interface is designed for future expansion — additional log points will be added as the adapter matures.