Skip to content

API reference

tempest-db-js's complete public surface. The core is imported from the package level; migrations live under the tempest-db-js/migrations subpath:

import {
  Model, column, sql,
  type InferModel, type InferInsert,
  select, insert, update, del,
  and, or, not,
  createEngine, createSyncEngine,
  join, hasMany, belongsTo, loadRelations,
  BaseRepository,
} from "tempest-db-js";

import { reflectSchema, diffSchema, MigrationRunner } from "tempest-db-js/migrations";

Living reference

This page summarizes the entire current public surface. The source of truth is the JSDoc docstrings in the code — the editor shows the full signature of each symbol in autocomplete.

Schema

Model

Abstract base class for every table. Subclasses define static tablename and column fields.

class User extends Model {
  static tablename = "users";
  id = column.integer().primaryKey();
}

column

A factory for typed columns (mirrors SQLAlchemy's generic types).

Method TS type SQL type
column.array(element) T[] TEXT[] / INTEGER[] (PostgreSQL)
column.smallInteger() number SMALLINT
column.integer() number INTEGER
column.bigInteger() bigint BIGINT
column.numeric(p?, s?) / column.decimal(p?, s?) string NUMERIC(p,s)
column.real() number REAL
column.double() number DOUBLE PRECISION
column.varchar(n) / column.string(n) string VARCHAR(n)
column.char(n) string CHAR(n)
column.text() string TEXT
column.boolean() boolean BOOLEAN
column.date() Date DATE
column.time({ timezone? }) string TIME
column.datetime({ timezone? }) Date DATETIME/TIMESTAMP
column.timestamp({ timezone? }) Date TIMESTAMP
column.blob() Uint8Array BLOB/BYTEA
column.json<T>() T JSON
column.jsonb<T>() T JSONB
column.uuid() string UUID
column.enum(...vals) literal union ENUM

Chainable modifiers (return a new Column with the flag applied):

Modifier Effect
.primaryKey() Marks it as PK; implies hasDefault.
.notNull() Makes the inferred type non-nullable.
.default(value) Default on insert (a T value or an sql expression); marks it optional on insert.
.onUpdate(value) Reapplied on every UPDATE (e.g. updated_at).

sql — portable defaults

Server-side expressions, rendered per dialect (à la SQLAlchemy's func):

Function Render Use
sql.now() CURRENT_TIMESTAMP / now() created_at/updated_at
sql.currentDate() CURRENT_DATE creation date
sql.currentTime() CURRENT_TIME time
sql.uuidv4() gen_random_uuid() / fallback UUID PK
sql.raw(expr) verbatim escape hatch
sql.expr`...${v}` parameterized fragment expression with a bound value

The default is stored in column.<field>.defaultValue / .onUpdateValue — it feeds the migration IR.

Every sql.* expression is branded (isSqlExpression) and works both as a column default and as a write value in .set() / .values(), where it is rendered inline instead of being bound as a parameter:

update(Outbound)
  .set({ attempts: sql.raw("attempts + 1"), updatedAt: sql.now() })
  .where({ id });

sql.expr cannot be a column default

A DEFAULT has nowhere to bind parameters — .default(sql.expr\...`)throws immediately. Usesql.raw()` there.

Column names

Symbol Does
.name("column") Maps the property to a different column name.
static naming "preserve" (default) or "snake_case" for the whole table.
columnNamesOf(Model) Property → column map, or null when nothing is renamed.
columnPropsOf(Model) The inverse map, column → property.
toSnakeCase(name) The conversion used by the "snake_case" strategy.

See the Column names recipe.

columnsOf(Model)

Reflects the class into its Columns at runtime (Record<string, Column>). The base of serialization and of the migrations schema reflector.

InferModel<typeof Model>

The read row type. notNull/primaryKey columns are non-nullable; the rest become T | null.

InferInsert<typeof Model>

The insert row type. Columns with a default (or PK) are optional (?); the rest are required.

SELECT

select(Model) / select(Model, columns)

Form Inferred result
select(User) InferModel<typeof User>[]
select(User, ["id", "name"]) Pick<InferModel<typeof User>, "id" \| "name">[]

SelectBuilder<Full, Proj>

Method Description
.where(input) Filters; keys typed against Full, operators typed per column.
.orderBy(column, direction?) Orders by column ("asc" | "desc", default "asc").
.limit(n) Limits the number of rows.
.offset(n) Skips the first n rows.
.forUpdate(options?) FOR UPDATE [OF ...] [SKIP LOCKED \| NOWAIT]. PostgreSQL/MySQL; SQLite throws.
.forShare(options?) Same, with a shared lock (FOR SHARE).
.aggregate(groupBy, spec) Groups; the builder then accepts .having().
.having(input) HAVING, by aggregate alias or grouped column. Only after .aggregate().
.asSubquery(column) Projects one column and marks the SELECT as an in/notIn operand.
.node The SelectNode AST (read-only).

LockOptions = { skipLocked?: boolean; noWait?: boolean; of?: readonly string[] }. skipLocked and noWait are mutually exclusive, and a lock combined with DISTINCT/an aggregate throws at compile time.

where operators (OperatorsFor<T>)

Each where value accepts an exact match (shorthand for eq) or an operator object restricted to the column's type:

Type Allowed operators
string eq, ne, in, notIn, like, ilike, ieq, isNull
number / bigint / Date eq, ne, in, notIn, gt, gte, lt, lte, between, isNull
boolean eq, ne, isNull
T[] (array) eq, ne, in, notIn, contains, containedBy, overlaps, isNull
json / blob eq, ne, in, notIn, isNull

ilike is a pattern, ieq is equality

% and _ are wildcards in like/ilike — { ilike: "%" } matches every row. For a case-insensitive lookup (login, e-mail) use ieq, which compiles to lower(col) = lower($1) and matches a functional index. See Case-insensitive comparison.

The array operators (contains → @>, containedBy → <@, overlaps → &&) are native to PostgreSQL; the other dialects throw an explicit error.

OPERATORS (runtime) and the Operator type list the full set. An operator that's invalid for the type = compile error.

Expressions: col, val, fn

To compare a column against a column, or apply a SQL function:

Symbol Does
col<Row>("column") A column reference (the property name, checked against Row).
val(x) A bound value — required inside fn.*, where a string means a column.
fn.lower/upper/trim/length/abs/coalesce Functions portable across all 3 dialects.
fn.call(name, ...args) Any function; the name is validated as an identifier and interpolated.

The comparison methods (eq, ne, gt, gte, lt, lte, like, ilike, ieq, in, notIn, between, isNull) return a Condition. An operand that is not an expression becomes a bound parameter; in/between refuse an expression.

select(Order).where(col<OrderRow>("total").gt(col<OrderRow>("paid")));
select(User).where(fn.lower("email").eq(fn.lower(val(probe))));

Details in Expressions in where.

Subqueries in in / notIn

in/notIn take a list or a single-column Subquery:

update(Outbound).set({ status: "sending" }).where({
  id: { in: select(Outbound).where({ status: "queued" }).asSubquery("id") },
});

The subquery carries its own name map and binds its parameters at the position it appears. MySQL rejects LIMIT inside one (an explicit compile-time error).

and / or / not combinators

The object form is an implicit AND. For composite logic, use the combinators (in select/update/delete/join):

Symbol Does
and(...args) (...) AND (...)
or(...args) (...) OR (...)
not(arg) NOT (...)

Each arg is the object form ({ col: ... }) or another combinator. Pass the row type (or<UserRow>(...)) for key-safety inside the combinator.

INSERT

insert(Model)

Returns InsertBuilder.

Method Description
.values(row \| rows) Typed by InferInsert<typeof Model>. Accepts 1 or N.
.onConflictDoNothing(target, options?) ON CONFLICT (target) [WHERE ...] DO NOTHING.
.onConflictDoUpdate(target, set, options?) Upsert, with optional indexWhere/updateWhere.
.returning() Result becomes the full row.
.returning(columns) Result becomes a Pick of the columns.

Without returning, the execution result is number (affected rows).

OnConflictOptions = { where? } (the predicate of a partial unique index — required on PostgreSQL for it to match as a conflict target). OnConflictUpdateOptions = { indexWhere?, updateWhere? }. Both take the same condition language as where. MySQL throws for any predicate.

Values in .values() and .set() accept the column's value or a sql.* expression; any other object raises ValidationError while the query is built.

UPDATE

update(Model)

Returns UpdateBuilder<Full, false> (unguarded).

Method Description
.set(values) WritePatch<Full> — only the columns you pass change; accepts a sql.* expression.
.where(input) Filters and marks Guarded = true.
.unguarded() Explicit opt-in to update all rows (Guarded = true).
.returning() / .returning(cols) As in insert.

DELETE

del(Model)

Returns DeleteBuilder<Full, false> (del because delete is reserved).

Method Description
.where(input) Filters and marks Guarded = true.
.unguarded() Explicit opt-in to delete all rows.
.returning() / .returning(cols) As in insert.

AST types

Exposed for tooling and dialects: SelectNode, InsertNode, UpdateNode, DeleteNode, OrderTerm, SortDirection, WhereInput, Returning, LockClause, LockOptions, OnConflict, OnConflictOptions, OnConflictUpdateOptions, WriteValues, WritePatch, NameMap, SqlExpression, ExprNode, Expression, Subquery.

Database URL

parseDatabaseUrl(url)

Parses a connection string and identifies the dialect, just like SQLAlchemy's make_url. Accepts (and ignores) an async driver suffix (+asyncpg, +aiosqlite).

import { parseDatabaseUrl, detectDialect } from "tempest-db-js";

parseDatabaseUrl("postgresql://app:secret@localhost:5432/mydb");
// { dialect: "postgresql", host: "localhost", port: 5432, user: "app",
//   password: "secret", database: "mydb", driver: null, options: {}, raw: "..." }

parseDatabaseUrl("sqlite:///app.db");      // { dialect: "sqlite", database: "app.db", ... }
detectDialect("sqlite://:memory:");        // "sqlite"
Symbol Description
parseDatabaseUrl(url) ParsedDatabaseUrl (dialect + connection parts).
detectDialect(url) Just the Dialect ("sqlite" \| "postgresql").
ParsedDatabaseUrl The result type.
InvalidDatabaseUrl Error thrown on a URL with no scheme or an unknown dialect.

Serialization

Converts between a row (native values), a dict, and JSON, with per-column-type coercion — à la Pydantic's model_dump / model_validate.

import { toDict, toJSON, stringify, fromDict, parse } from "tempest-db-js";

toJSON(User, row);        // { ...JSON-safe: Date→ISO, bigint→string, blob→base64 }
toDict(User, row);        // { ...native, known columns only }
stringify(User, row);     // JSON string
fromDict(User, payload);  // validated row (coerces string→Date/bigint/Uint8Array; JSON.parse)
parse(User, jsonString);  // fromDict(JSON.parse(...))
Function Does
toDict(Model, row) Dict of native values, restricted to the columns.
toJSON(Model, row) JSON-safe object (Date→ISO, bigint→string, Uint8Array→base64).
stringify(Model, row) JSON.stringify(toJSON(...)).
fromDict(Model, data) Validated row from a dict; coerces types; validates required ones.
parse(Model, json) fromDict(Model, JSON.parse(json)).
ValidationError Thrown when a required column is missing.

SQL compilation (dialects)

A builder's AST becomes parameterized SQL via a dialect — the only place where SQL is born. Always placeholders (? in SQLite, $1 in Postgres), never interpolation (injection-safe by construction). compile only builds the SQL; the session runs it (see Execution below).

import { getDialect, select, Model, column } from "tempest-db-js";

const sqlite = getDialect("sqlite");
const compiled = sqlite.compile(
  select(User).where({ age: { gte: 18 } }).orderBy("name").limit(10).node,
);
// { sql: 'SELECT * FROM "users" WHERE "age" >= ? ORDER BY "name" ASC LIMIT ?',
//   params: [18, 10] }
Symbol Description
getDialect("sqlite" \| "postgresql") A dialect instance.
BaseDialect.compile(node) CompiledQuery ({ sql, params }).
SqliteDialect / PostgresDialect Concrete implementations.
CompiledQuery { sql: string; params: readonly unknown[] }.
QueryNode The union of compilable ASTs.

Differences per dialect: placeholder (? vs $1), ilike (native ILIKE in Postgres; LIKE in SQLite, case-insensitive in ASCII), row locking (FOR UPDATE on Postgres/MySQL, an error on SQLite), the ON CONFLICT predicate (Postgres/SQLite, an error on MySQL) and native arrays (PostgreSQL only).

Execution (engine / session)

Database identified by URL; execution async by default, sync optional for SQLite.

Symbol Description
createEngine(url, opts?) AsyncEngine (SQLite or PostgreSQL).
createSyncEngine(url, opts?) SyncEngine (SQLite; throws on Postgres).
engine.session() Opens a Session/SyncSession.
engine.transaction(fn) Transactional block (automatic commit/rollback).
engine.close() Closes the driver.
session.execute(builder) Runs and coerces; returns a Result.
session.raw(sql, params?, opts?) Raw parameterized statement; same Result. { as: Model } coerces rows.
toAsyncDriver(driver) Adapts a sync or async driver to the async interface (used by the CLI).
session.stream(builder) Lazy iteration (sync: Iterable; async: AsyncIterable).
session.beginNested(fn) Savepoint (nested transaction), on both sessions. Recoverable: a failure discards only the savepoint.
transaction(fn, { isolation, readOnly }) Block characteristics. SQLite only accepts serializable; a level or readOnly on a nested block throws.
session.unitOfWork() Identity map plus change log; get/track/add/remove and flush() in one transaction.
session.transactionDepth / session.inTransaction Depth of open blocks; a nested transaction() joins the outer one.
createEngine(url, { pool }) PoolOptions (size/idleTimeoutMs/connectTimeoutMs/prePing/recycleMs) — PostgreSQL.
engine.explain(fn, opts?) The plan of every statement in the block. analyze executes, and is refused for writes.
createEngine(url, { onQueryEnd, slowQueryMs }) Hook fired after the statement: durationMs, rowCount, error. slowQueryMs filters by threshold.
createEngine(url, { onNotice }) Server-side notices (PG NOTICE); without it, nothing is printed.
createEngine(url, { driverOptions }) Passed straight to the driver, applied last (wins over pool/onNotice).
createEngine(url, { driver }) Picks the SQLite driver: "node:sqlite" (default) or "better-sqlite3". An unknown name throws.
NodeSqliteDriver / BetterSqliteDriver Both SQLite drivers; .open(path, options?) opens a handle directly.
withTimestamps(Base) / withSoftDelete(Base) / withAudit(Base, actor?) Model mixins: createdAt/updatedAt, deletedAt, createdBy/updatedBy.
notDeleted() / onlyDeleted() where fragments for the soft-delete mixin.
outboxModel(table) / OutboxRepository The outbox schema and the relay: publish, pending, claim, markSent, markFailed.
TenantScopedRepository(Model, session, { column, id }) Injects the tenant into every read and stamps every write; another tenant throws.
repo.existsExcluding(filters, key) Uniqueness on an update, ignoring the row itself.
repo.bulkUpsert(rows, opts) Batch ON CONFLICT, with sql.excluded() in the SET.
repo.softDelete(key) / repo.restore(key) Require deletedAt (mixin); throw without it.
repo.deleteBatch(keys) DELETE ... IN (...); a composite PK throws.
repo.changesSince(filter) Delta sync: a page plus serverTime as the watermark.
repo.cursorPaginate(filter) A cursor page: { items, nextCursor }, no COUNT(*), primary key as tie-break. InvalidCursor on a bad cursor.
select(...).compute({ alias: expr }) Projects an expression by alias, without grouping — where windows live.
over(fn, { partitionBy, orderBy, frame }) rowNumber/rank/denseRank/lag/lead/firstValue/lastValue and aggregates.
cte(name, Model, body) / cteRecursive(name, Model, self => body) WITH / WITH RECURSIVE; .model works wherever a model does.
join(...).pick(alias) Projects one join source, flat — what a UNION/CTE branch requires.
union / unionAll / intersect / except Combine SELECTs; branch shapes checked by the types. The builder's ORDER BY/LIMIT apply to the set.
aliased(Model, alias) Reads the same model under another name (FROM t AS a) — self-joins and same-table correlated subqueries.
exists(sub) / notExists(sub) Correlated EXISTS (...); col("table.column") references the outer query.
scalar(sub) A one-column subquery as a value; requires .asSubquery(col).
insert(M).fromSelect(cols, query) INSERT ... SELECT — the rows never pass through the process.
update(M).from(Other, alias) / del(M).using(Other, alias) Writes that read another table; explicit error where the dialect lacks it.
caseWhen(branches, else?) / cast(expr, type) CASE WHEN ... END and CAST(x AS type); the type is portable and mapped per dialect.
sum/avg/min/max(column \| expression) Aggregation over an expression — what makes SUM(CASE WHEN ...) possible.
parseIntegrityError(error, Model?) Driver error → { violation, constraint, table, columns, detail }, or null.
contains(cols, term, opts?) / { iContains } Case-insensitive escaped substring, portable.
fullText(cols, term, opts?) / fullTextRank(...) Full text on PostgreSQL; falls back to contains elsewhere.
escapeLike(value) Escapes %, _ and \\ for use with raw like/ilike.
auditLogModel(table) / enableAudit(Model, opts) Append-only trail with a diff; written in the change's own transaction.
onSignal(Model, signal, handler) preSave/postSave/preDelete/postDelete on the repository path; a throwing pre* vetoes.
hasHandlers / clearSignals Whether anything listens (the repository skips the extra SELECT otherwise); test cleanup.
backupDatabase(url, file) / restoreDatabase(url, file) pg_dump/pg_restore/psql, or VACUUM INTO on SQLite. Also tempest-db backup\|restore.
customType({ base, toDb, fromDb }) A column type of your own; converts on writes, reads and where. The schema keeps the base type.
check(condition, opts?) / index(columns, opts?) tableArgs with CHECK and indexes (partial and unique too). Drift compares indexes, not CHECKs.
primaryKeysOf(Model) / primaryKeyFilter(Model, key) The primary-key columns (an array — composite means more than one) and the filter identifying one row.
createEngine(url, { sqlite }) Per-connection pragmas: foreignKeys (defaults to true), journalMode, busyTimeoutMs, synchronous. Refused by SQLite ⇒ error.

Result terminals (async ones return a Promise):

Terminal Returns
.all() Row[]
.first() Row \| null
.one() Row (error NoResultError if ≠ 1)
.oneOrNull() Row \| null (error if > 1)
.scalar() value of the 1st column \| null
.scalars() values of the 1st column []
.rowsAffected() number

Drivers: SQLite via the built-in node:sqlite (NodeSqliteDriver); PostgreSQL via postgres.js (lazy). The update/del guard is required by execute (the Executable type).

session.raw is the way out for the query the builder cannot yet express — it goes through the same onQuery, the same QueryExecutionError and the transaction's reserved connection. Never interpolate values into the string: they go in params. See Raw SQL at runtime.

Joins

Symbol Description
join(Model, alias) Starts a JoinBuilder<{ [alias]: Row }>.
.innerJoin(Model, alias, on) Inner join; adds { [alias]: Row }.
.leftJoin(Model, alias, on) Left join; adds { [alias]: Row \| null }.
.where(input) Filters by typed alias.column refs.
.orderBy(ref, dir?) / .limit(n) / .offset(n) As in select.

on maps refs of existing sources to refs of the new table (equality): { "user.id": "order.userId" }. The result is one object per alias, coerced per model; leftJoin produces null when there's no match.

Relations

Symbol Does
hasMany(() => Target, { localKey, foreignKey }) 1-N relation.
belongsTo(() => Target, { localKey, foreignKey }) N-1 relation.
loadRelations(session, rows, spec) Eager-load (1 query/relation); typed result.

hasMany → Row[]; belongsTo → Row | null.

Migrations (tempest-db-js/migrations)

Symbol Does
reflectSchema(models) / reflectTable(model) Model → Schema IR.
diffSchema(current, target) IR × IR → Operation[].
invert / invertAll Inverse of operations (for down()).
renderOperation(op, dialect) Operation → SQL.
generateMigration(draft) Codegen of a TS file.
topoOrder / heads DAG ordering + tips.
MigrationRunner / Op Applies/reverts; version table.
replaySchema(migrations) "Current" IR without a database.
introspectSqlite / checkDrift Live schema + drift (SQLite).
introspectPostgres / checkDriftPostgres Same (PostgreSQL, structural).
runMigrationCli(argv, config) CLI: upgrade/downgrade/check/revision...