Skip to content

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

bash
npm install @dbsp/core @dbsp/adapter-pgsql pg
# or
pnpm add @dbsp/core @dbsp/adapter-pgsql pg

Define Your Schema

typescript
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

typescript
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')
                              orm.from(orm.tables.users)


                              QueryBuilder<{ id: string; name: string }>


                                                .all()  →  Promise<Array<{ id: string; name: string }>>

Column types map to TypeScript types:

Schema TypeTypeScript TypeNotes
'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:

typescript
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

typescript
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

TypePostgreSQLTypeScript
'string'VARCHARstring
'text'TEXTstring
'integer'INTEGERnumber
'bigint'BIGINTbigint
'decimal'DECIMALnumber
'boolean'BOOLEANboolean
'date'DATEDate
'time'TIMEstring
'timestamp'TIMESTAMP WITH TIME ZONEDate
'json'JSONunknown
'jsonb'JSONBunknown
'uuid'UUIDstring
'daterange'DATERANGE[Date, Date]
'tstzrange'TSTZRANGE[Date, Date]
'int4range'INT4RANGE[number, number]

Column Options

Shorthand (type only) or object with options:

typescript
// 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 },
}
OptionTypeDescription
typeColumnTypeColumn data type (required in object form)
primaryKeybooleanMark as primary key
autoIncrementbooleanAuto-increment (serial)
nullablebooleanAllow NULL values
uniquebooleanUnique constraint
indexbooleanCreate index
defaultstringDefault value expression

Relations with ref()

typescript
// 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
typescript
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

OptionTypeDescription
nullablebooleanOptional relation (LEFT JOIN)
uniquebooleanMakes 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
asstringLocal relation name override
inversestringReverse relation name on target table
rolesSelfRefRolesRole names for self-referential relations
columnsstring[]Source columns (composite FK)
referencesstring[]Target columns (defaults to PK)

Self-Referential Relations

For trees/hierarchies, use roles to name the traversal paths:

typescript
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)

typescript
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:

typescript
const db = schema({
  users: {
    firstName: 'string',  // JS: camelCase
    lastName: 'string',
  },
}, undefined, { dbCasing: 'snake_case' });
// DB columns: first_name, last_name
// JS properties: firstName, lastName
ValueDB ColumnsJS PropertiesTransform
'snake_case'first_namefirstNameAuto camelCase <-> snake_case
'camelCase'firstNamefirstNameNo transform
'preserve'as-isas-isNo transform

Schema Options (SchemaOptions)

The third argument to schema() accepts a SchemaOptions object that controls schema-wide DSL interpretation:

typescript
// doctest: skip — signature reference
schema(definition, constraints?, options?, extras?)
OptionTypeDefaultDescription
defaultFiltersRecord<string, WhereIntent>Filters applied automatically to all queries per table. Override with .withoutDefaultFilters() on the query builder.
defaultPkColumnNamestring | 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

typescript
// 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:

  1. Explicit primaryKey: true on a column
  2. Column matching defaultPkColumnName (the implicit convention) — skipped when set to null
  3. FK columns (composite, for junction tables — applies regardless of defaultPkColumnName)
  4. No primary key

Schema Constraints

Add composite indexes and foreign keys via the constraints parameter:

typescript
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

typescript
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

OptionTypeDefaultDescription
schemaSchema-From schema() (preferred)
modelModelIR-Direct ModelIR (alternative to schema)
adapterAdapter-Database adapter (optional for compile-only)
strictModebooleanfalseThrow on ambiguous relations
maxDepthnumber10Max recursive depth
maxTableHopsnumber5Max relation hops

Compile-Only Mode (No Database)

For tooling, testing, or SQL preview without a database connection:

typescript
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 ExecutionError

Schema Scoping (Multi-Tenant)

typescript
const tenantOrm = orm.withSchema('tenant_123');
const users = await tenantOrm.select('users').dump();
// SQL: SELECT * FROM "tenant_123"."users"

Transactions

typescript
// 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 error

The callback receives a transaction-scoped ORM instance (tx) with the full ORM API. The transaction result is the return value of your callback:

typescript
// 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:

typescript
// 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();
  });
});
BehaviorDetail
SuccessCOMMIT after callback returns
Error thrownROLLBACK, error re-thrown
Nested callReuses parent transaction (no savepoints)
ConnectionDedicated 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

select(name) is the ordinary table-name entry point. It is concise and typed by table name when the ORM is created from a typed schema. Use from(orm.tables.<table>) when you want the stricter TableRef form with column-level types.

typescript
const users = await orm.select('users').dump();

The same query using both forms:

typescript
const activeUsersQuery = await orm
  .select('users')
  .where(eq('active', true))
  .dump();

const { users } = orm.tables;
const activeUsersFromRefQuery = await orm
  .from(users)
  .where(eq(users.active, true))
  .dump();

from() - Start a Query from a TableRef

from(tableRef) starts from a table reference in orm.tables. It returns the same query builder surface as select(name), while carrying column-level types into filters and result inference.

typescript
const { users } = orm.tables;

const aliceQuery = await orm
  .from(users)
  .where(eq(users.email, 'alice@example.com'))
  .columns(['id', 'name'])
  .dump();

columns() — Select Specific Columns

typescript
const names = await orm.select('users').columns(['id', 'name']).dump();
// SQL: SELECT "id", "name" FROM "users"

distinct() — Remove Duplicates

typescript
// 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"

distinctOn() — PostgreSQL DISTINCT ON

Use distinctOn() for PostgreSQL's DISTINCT ON (...). Plain columns and relation-qualified columns are both supported. Relation paths such as users.email are resolved through the planner's final relation alias, so alias bumps from joins/includes do not point at the wrong table.

typescript
const latestPostByAuthor = orm.select('posts')
  .include('users', { join: 'inner' })
  .distinctOn('users.email')
  .columns(['id', 'title'])
  .dump();

console.log(latestPostByAuthor.sql);

where() — Filter Rows

Uses filter helpers (see full list below):

typescript
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

HelperSQLExample
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)LIKElike('name', 'A%')
inArray(field, values)INinArray('id', [1, 2, 3])
isNull(field)IS NULLisNull('deletedAt')
isNotNull(field)IS NOT NULLisNotNull('email')
isDistinctFrom(field, value)IS DISTINCT FROMisDistinctFrom('status', 'active')
and(...conditions)ANDand(eq('a', 1), gt('b', 2))
or(...conditions)ORor(eq('x', 1), eq('x', 2))
not(condition)NOTnot(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).

typescript
// 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 $1

Relation Filters

Filter by related records without loading them:

typescript
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)

typescript
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

typescript
// 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

typescript
orm.select('posts').orderBy('createdAt', 'desc').limit(10).offset(20)

Aggregates

typescript
// 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()

typescript
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" > $1

Window Functions

typescript
// 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

typescript
// 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

typescript
// 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.

typescript
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.

typescript
// 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 users

Set Operations

Combine query results with UNION, INTERSECT, or EXCEPT. All variants support the All suffix (e.g., .unionAll()) to preserve duplicates.

typescript
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

typescript
const usersWithPosts = await orm.select('users').include('posts').dump();
// [{ id: 1, name: 'Alice', posts: [{ id: 1, title: '...' }, ...] }]

Dot Notation (Deep Nesting)

typescript
orm.select('users').include('posts.comments')
orm.select('users').include('posts.comments.author')

Multiple Includes

typescript
orm.select('users')
  .include('posts')
  .include('profile')
  .include('posts.comments')
  .dump()

Include Options

typescript
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
})
OptionTypeDescription
whereWhereIntentFilter conditions on related records
selectSelectSpecSelect specific columns
viastringDisambiguate multiple relations to same table
recursivebooleanEnable recursive CTE traversal
direction'ancestors' | 'descendants'Traversal direction (required when recursive)
flatbooleanFlat array output with depth field
maxDepthnumberMaximum traversal depth (default: 100)

Recursive Includes (Hierarchies)

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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)

typescript
// 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 — update only when the existing row matches a predicate
orm.upsert('users')
  .values({ name: 'Alice', email: 'alice@example.com', active: true })
  .onConflict(['email'])
  .doUpdate({ name: 'Alice Updated' }, eq('active', true))
  .dump();

// On conflict — update only when a related row exists
const guarded = orm.upsert('users')
  .values({
    id: 'user-1',
    name: 'Alice',
    email: 'alice@example.com',
    active: true,
  })
  .onConflict(['email'])
  .doUpdate(
    { active: true },
    exists('posts', { where: eq('published', true) }),
  )
  .dump();

console.log(guarded.sql);
console.log(guarded.parameters);

// 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():

typescript
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

MethodReturnsDescription
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)
typescript
// 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:

typescript
// 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:

sql
SELECT EXISTS(SELECT 1 FROM users WHERE users.active = $1) AS "exists"

Key behaviors:

  • Returns true if at least one row matches, false otherwise
  • Strips orderBy (irrelevant for existence)
  • Strips include (related data not needed)
  • Preserves where, groupBy, having, offset
  • Sets internal limit: 1 for optimization

Debugging: Use existsDump() to inspect the generated intent without executing:

typescript
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: true

Streaming

typescript
// 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

typescript
// 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`);
}
OptionTypeDescription
chunkSizenumberRows per cursor fetch batch
onStart(dump: Dump) => voidCallback invoked once before first row; receives full query dump (SQL, params, plan)

Pagination

Offset-Based

typescript
// 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 — true

Cursor-Based

typescript
// 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

typescript
// 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.

ErrorCodeWhen
ExecutionErrorDBSP_E001Executing without adapter configured
NotFoundErrorDBSP_E002firstOrThrow() / byIdOrThrow() finds nothing
AmbiguousRelationErrorDBSP_E003Strict mode + ambiguous relation
RelationNotFoundErrorDBSP_E004Requested relation doesn't exist
InvalidOperationErrorDBSP_E005Malformed operation
UnsafeOperationErrorDBSP_E006update()/delete() without WHERE
TableNotFoundErrorDBSP_E007Table not in schema
ColumnNotFoundErrorDBSP_E008Column not on table
typescript
// 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):

typescript
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:

typescript
const dump = orm.nql`users | where active = ${true}`.dump();
console.log(dump.params); // [true]

const mutationDump = orm.nql<unknown>`
  insert into users set name = ${'Alice'}, email = ${'alice@example.com'}
`.dump() as {
  sql: string;
  parameters: readonly unknown[];
};
console.log(mutationDump.parameters); // ['Alice', 'alice@example.com']

Interpolated values are bound as SQL parameters. NQL tag mutations support .dump(), .run(), and .all(); executed tag mutations run the normal mutation hook lifecycle. Use nqlRaw() only for trusted NQL structure. See Named Parameters and Template Binding and TypeScript Tag Mutations for the detailed binding and mutation contracts.

Hierarchy Shortcuts

typescript
// 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

typescript
// 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)

typescript
// 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:

typescript
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:

sql
-- 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

typescript
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}`,
});
OptionTypeDefaultDescription
defaultPkColumnNamestring'id'Convention fallback when schema metadata doesn't provide an explicit PK column
deriveFkColumnName(table: string, pk: string) => stringsingularize(table)_pkDerives 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:

typescript
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 columns

Two built-in plugins:

PlugindbCasingEffect
IdentityNamingPlugin'preserve' (default)No transformation — columns pass through unchanged
CamelCaseNamingPlugin'snake_case'createdAtcreated_at, userProfileImageuser_profile_image

The CamelCaseNamingPlugin handles edge cases: acronyms (parseJSONparse_json), numbers (field1Namefield1_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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
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→phenomenon

Putting It Together

A fully custom FK naming strategy using a Map of overrides:

typescript
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:

typescript
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);
    },
  },
});
MethodWhen 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.

Released under the MIT License.