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.
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.
| Database | Import | Pass | Driver |
|---|---|---|---|
| PostgreSQL | import { postgres } from 'kastor-orm/postgres' | new pg.Pool(...) | pg |
| CockroachDB | import { cockroach } from 'kastor-orm/cockroach' | new pg.Pool(...) | pg |
| MySQL | import { mysql } from 'kastor-orm/mysql' | mysql2.createPool(...) | mysql2 |
| MariaDB | import { mariadb } from 'kastor-orm/mariadb' | mariadb.createPool(...) | mariadb |
| SQLite | import { sqlite } from 'kastor-orm/sqlite' | new Database('app.db') | better-sqlite3 |
| libSQL / Turso | import { libsql } from 'kastor-orm/libsql' | createClient({ url, authToken }) | @libsql/client |
| Cloudflare D1 | import { d1 } from 'kastor-orm/d1' | env.DB | D1 binding |
| SQL Server | import { mssql } from 'kastor-orm/mssql' | await pool.connect() | mssql |
| Oracle | import { oracle } from 'kastor-orm/oracle' | await oracledb.createPool(...) | oracledb |
| MongoDB | import { 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.
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.
| Builder | TypeScript type | Notes |
|---|---|---|
t.id() | number | Auto increment integer primary key |
t.bigId() | bigint | Auto increment 64 bit primary key |
t.uuid() | string | Use with .defaultRandom() |
t.string(len?) | string | varchar, nvarchar or varchar2 |
t.text() | string | Long text |
t.int() | number | 32 bit integer |
t.bigint() | bigint | 64 bit integer |
t.float() | number | Double precision |
t.decimal(p, s) | string | Exact, kept as a string |
t.boolean() | boolean | Native or 0 and 1 |
t.datetime() | Date | Timestamp |
t.date() | string | YYYY-MM-DD |
t.time() | string | HH:MM:SS |
t.json<T>() | T | Parsed JSON |
t.bytes() | Uint8Array | Binary |
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.
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.
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.
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.
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.
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.
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.
| Operator | Meaning |
|---|---|
equals, not | Equal, not equal (a plain value means equals) |
in, notIn | In a list |
lt, lte, gt, gte | Comparisons |
contains, startsWith, endsWith | Text matching, wildcards escaped for you; add mode: 'insensitive' |
AND, OR, NOT | Logic |
some, every, none | To-many relation filters |
is, isNot | To-one relation filters |
null, not: null | Null 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.
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.
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.
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.
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.
| Code | When |
|---|---|
UNIQUE_VIOLATION | A unique column or index already has the value |
FOREIGN_KEY_VIOLATION | The referenced row does not exist, or rows still point to it |
NOT_NULL_VIOLATION | A required column got no value |
CHECK_VIOLATION | A check constraint failed |
SERIALIZATION_FAILURE | Retry the transaction |
DEADLOCK | Two transactions waited on each other |
CONNECTION_FAILED | The database could not be reached |
TIMEOUT | The 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.
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.
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.
| Command | What it does |
|---|---|
init --db postgres | Writes 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 baseline | Adopts 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 deploy | Applies pending migrations, for CI and production |
migrate status | Lists applied and pending migrations |
migrate reset | Drops everything, applies every migration again and runs the seed |
db push | Syncs the schema without a migration file, for prototypes |
db pull | Writes a TypeScript schema from an existing database (--out for another file, --print for stdout) |
db seed | Runs the seed file |
import prisma | Converts 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 |
studio | Opens the local studio on port 5555 (--port, --read-only, --no-open) |
doctor | Checks the config, the connection, drift and pending migrations (--json for machine readable output) |