kastor0.1.0
Get started

SPECAPI reference

kastor API reference

Every public export of kastor-orm and its adapters, the client methods, filters, errors, the config file and the CLI. Types are inferred from your schema, so the examples are all the code you write.

Client

Create one client per process and reuse it. kastor ships no drivers: install the one you want and pass its instance to the adapter.

kastor(config)

kastor({ adapter, schema, relations?, log? }): KastorClient

Creates the client. schema is an object of tables, relations a function that receives the tables and returns the relations of each one, log prints every statement when set to true or calls your function with it.

src/db/index.ts
import pg from 'pg'
import { kastor, one, many } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { users, posts } from './schema'

export const db = kastor({
  adapter: postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL })),
  schema: { users, posts },
  relations: ({ users, posts }) => ({
    users: { posts: many(posts) },
    posts: { author: one(users, { fields: ['authorId'], references: ['id'] }) },
  }),
})

Adapters

Each adapter lives in its own entry point so your bundle only carries the dialect you use. On MongoDB, t.id() keys are ObjectIds exposed as strings (and so are the columns that reference them); pass mongodb(db, { ids: 'int' }) for numeric keys like on SQL, or ids: 'uuid' for UUID strings.

DatabaseImportPassDriver
PostgreSQLimport { postgres } from 'kastor-orm/postgres'new pg.Pool(...)pg
CockroachDBimport { cockroach } from 'kastor-orm/cockroach'new pg.Pool(...)pg
MySQLimport { mysql } from 'kastor-orm/mysql'mysql2.createPool(...)mysql2
MariaDBimport { mariadb } from 'kastor-orm/mariadb'mariadb.createPool(...)mariadb
SQLiteimport { sqlite } from 'kastor-orm/sqlite'new Database('app.db')better-sqlite3
libSQL / Tursoimport { libsql } from 'kastor-orm/libsql'createClient({ url, authToken })@libsql/client
Cloudflare D1import { d1 } from 'kastor-orm/d1'env.DBD1 binding
SQL Serverimport { mssql } from 'kastor-orm/mssql'await pool.connect()mssql
Oracleimport { oracle } from 'kastor-orm/oracle'await oracledb.createPool(...)oracledb
MongoDBimport { mongodb } from 'kastor-orm/mongodb'client.db('app')mongodb

Client members

$transaction, $sql, $execute, $schema (the schema as plain JSON), $dialect and $disconnect() which closes the pool you passed in.

Schema

Tables are plain TypeScript values. Columns are database neutral and every adapter maps them to native types.

table(name, columns, options?)

table('users', { ... }, { indexes?, primaryKey?, softDelete? })

Declares a table. indexes: (cols) => [index(cols.a), uniqueIndex(cols.a, cols.b)] adds indexes, primaryKey: (cols) => [cols.a, cols.b] sets a composite key and softDelete: 'deletedAt' turns deletes into updates of that column. Columns are available as properties, users.email.

src/db/schema.ts
import { table, t, index, uniqueIndex } from 'kastor-orm'

export const memberships = table('memberships', {
  userId: t.int().references(() => users.id, { onDelete: 'cascade' }),
  teamId: t.int().references(() => teams.id, { onDelete: 'cascade' }),
  role: t.enum(['member', 'owner']).default('member'),
  joinedAt: t.datetime().defaultNow(),
}, {
  primaryKey: (m) => [m.userId, m.teamId],
  indexes: (m) => [index(m.teamId)],
})

t

Column builders and the TypeScript type each one infers.

BuilderTypeScript typeNotes
t.id()numberAuto increment integer primary key
t.bigId()bigintAuto increment 64 bit primary key
t.uuid()stringUse with .defaultRandom()
t.string(len?)stringvarchar, nvarchar or varchar2
t.text()stringLong text
t.int()number32 bit integer
t.bigint()bigint64 bit integer
t.float()numberDouble precision
t.decimal(p, s)stringExact, kept as a string
t.boolean()booleanNative or 0 and 1
t.datetime()DateTimestamp
t.date()stringYYYY-MM-DD
t.time()stringHH:MM:SS
t.json<T>()TParsed JSON
t.bytes()Uint8ArrayBinary
t.enum(['a', 'b'])'a' | 'b'Native enum or a check

Column modifiers

.nullable() adds | null, .default(value), .defaultNow(), .defaultRandom(), .defaultSql('...'), .unique(), .primaryKey(), .references(() => other.col, { onDelete, onUpdate }), .map('db_column') for a different column name in the database and .nativeType('varchar(64)') to pick the exact native type.

one() and many()

one(target, { fields, references }?) many(target, { through }?)

Relations are declared next to the client, not in the tables, so tables never import each other in a cycle. Leave the config out of the side without the foreign key. For many to many, pass the join table with through.

example.ts
relations: ({ users, posts, tags, postTags }) => ({
  users: { posts: many(posts) },
  posts: {
    author: one(users, { fields: ['authorId'], references: ['id'] }),
    tags: many(tags, { through: postTags }),
  },
})

InferSelect and InferInsert

Row types for your own functions: type User = InferSelect<typeof users> is what queries return, InferInsert what create accepts (defaults and nullable columns are optional).

Reading

Every model on the client (db.users) has the same methods. Calls return a lazy query: nothing runs until you await it, and query.toSQL() returns the statements without running them.

findMany

findMany({ where?, select?, include?, orderBy?, take?, skip?, cursor?, distinct? })

All matching rows, as an array.

example.ts
await db.posts.findMany({ where: { published: true }, orderBy: { id: 'desc' }, take: 20, skip: 40 })

findFirst and findFirstOrThrow

The first matching row or null. The OrThrow version throws a KastorError with code NOT_FOUND.

findUnique and findUniqueOrThrow

One row by a primary key or unique column.

example.ts
await db.users.findUnique({ where: { email: 'ada@example.com' } })

findPage

findPage({ first, after?, before?, where?, orderBy?, select?, include? })

Cursor pagination with opaque cursors. Returns { items, pageInfo }, where pageInfo holds startCursor, endCursor and hasNextPage. Pass after: pageInfo.endCursor for the next page, before for the previous one. first is the page size, 1 to 1000.

example.ts
const page = await db.posts.findPage({ first: 20, orderBy: { id: 'desc' } })
const next = await db.posts.findPage({ first: 20, orderBy: { id: 'desc' }, after: page.pageInfo.endCursor })

include

Loads relations, nested as deep as you like, each level with its own where, orderBy, take, skip, select and include. _count counts related rows. One query per root call: JSON aggregation on SQL databases, $lookup stages on MongoDB.

example.ts
await db.users.findMany({
  include: {
    posts: { where: { published: true }, take: 3, include: { tags: true } },
    _count: { select: { posts: true } },
  },
})

select

Picks columns and relations. The result type keeps only what you selected.

example.ts
await db.users.findMany({ select: { id: true, email: true, posts: { select: { title: true } } } })

count, aggregate and groupBy

count returns a number. aggregate computes _count, _sum, _avg, _min and _max. groupBy groups by columns with the same aggregates, having and ordering by aggregates.

Filters

Where trees combine column filters, logic and relation filters.

Operators

Every operator works on every database.

OperatorMeaning
equals, notEqual, not equal (a plain value means equals)
in, notInIn a list
lt, lte, gt, gteComparisons
contains, startsWith, endsWithText matching, wildcards escaped for you; add mode: 'insensitive'
AND, OR, NOTLogic
some, every, noneTo-many relation filters
is, isNotTo-one relation filters
null, not: nullNull checks

Writing

create, createMany and createManyAndReturn

Insert one row (returned, with select or include if you like), many rows (returns { count }) or many rows and get them back. Nested create and connect write related rows in the same transaction.

example.ts
await db.users.create({
  data: { email: 'kas@example.com', posts: { create: [{ title: 'First log' }] } },
})

update, updateMany and upsert

Numbers accept { increment }, { decrement }, { multiply } and { divide }. upsert uses the native syntax of each database.

example.ts
await db.posts.update({ where: { id: 1 }, data: { views: { increment: 1 } } })
await db.users.upsert({ where: { email }, create: { email }, update: { name } })

delete and deleteMany

On soft delete tables they set the column instead; restore, hardDelete and hardDeleteMany are there too.

Transactions and raw SQL

$transaction

A callback that receives a transaction client, or an array of queries that run together. A nested call reuses the open transaction.

example.ts
await db.$transaction(async (tx) => {
  const user = await tx.users.create({ data: { email } })
  await tx.posts.create({ data: { title: 'Welcome', authorId: user.id } })
})

$sql, $execute and sql

Tagged templates: values become parameters. $sql returns rows typed by the generic you pass, $execute the affected row count. The sql tag builds fragments for where with sql.raw, sql.join and sql.ident.

example.ts
const rows = await db.$sql<{ id: number; total: number }>`
  select author_id as id, count(*) as total from posts where views > ${min} group by author_id
`

Errors

KastorError and isKastorError

Every failure is a KastorError with a stable code, a plain English message, a hint with the fix, meta and the driver error as cause.

CodeWhen
UNIQUE_VIOLATIONA unique column or index already has the value
FOREIGN_KEY_VIOLATIONThe referenced row does not exist, or rows still point to it
NOT_NULL_VIOLATIONA required column got no value
CHECK_VIOLATIONA check constraint failed
SERIALIZATION_FAILURERetry the transaction
DEADLOCKTwo transactions waited on each other
CONNECTION_FAILEDThe database could not be reached
TIMEOUTThe query or the connection timed out

Config, seeds and CLI

defineConfig

kastor.config.ts tells the CLI where the schema is, how to connect, where migrations live and which seed to run.

kastor.config.ts
import pg from 'pg'
import { defineConfig } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'

export default defineConfig({
  schema: './src/db/schema.ts',
  adapter: () => postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL })),
  migrations: './kastor/migrations',
  seed: './src/db/seed.ts',
})

defineSeed and factory

defineSeed<Db>(fn) factory(db.model, ({ seq }) => data)

defineSeed types the default export of your seed file, which npx kastor db seed calls with the client. factory takes a model of the client and a defaults function that receives a sequence number, and returns build, buildMany, create, createMany and reset.

test/factories.ts
import { factory } from 'kastor-orm'
import { db } from '../src/db'

export const users = factory(db.users, ({ seq }) => ({ email: `user${seq}@example.com` }))

const ada = await users.create({ name: 'Ada' })
const ten = await users.createMany(10)

npx kastor

The command line. Every command takes --config and --help. The npm package is kastor-orm and the command is kastor: run npx kastor-orm init before the package is installed, and npx kastor migrate dev and the other commands once it is.

CommandWhat it does
init --db postgresWrites kastor.config.ts, a starter schema and a seed file for the database you pick
migrate dev --name <name>Diffs the schema against the database, writes kastor/migrations/<timestamp>_<name>/migration.sql and applies it. --create-only writes without applying, --rename users.name:fullName answers a rename question up front, --accept-data-loss allows destructive changes without asking
migrate baselineAdopts an existing database: writes its current schema as the first migration and records it as applied without running it. --name init names the file, --from-prisma prisma/migrations also records the Prisma migrations that ran as history. See Migrate to kastor
migrate deployApplies pending migrations, for CI and production
migrate statusLists applied and pending migrations
migrate resetDrops everything, applies every migration again and runs the seed
db pushSyncs the schema without a migration file, for prototypes
db pullWrites a TypeScript schema from an existing database (--out for another file, --print for stdout)
db seedRuns the seed file
import prismaConverts schema.prisma into a kastor schema for the same database and prints a report of anything that behaves differently. --schema picks the Prisma file or folder, --out the output file, --config also writes kastor.config.ts. See Migrate from Prisma
studioOpens the local studio on port 5555 (--port, --read-only, --no-open)
doctorChecks the config, the connection, drift and pending migrations (--json for machine readable output)

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.