Kysely on steroids: author database schemas in YAML, infer Kysely types, apply schema-diff migrations, and query through a compact model API.
npm i paranorm kyselyKeep the YAML as a literal so TypeScript can infer its database type:
import { Kysely } from "kysely";
import { paranorm, defineSchema, type InferSchema } from "paranorm";
const schema = defineSchema(`
_version: "1.0.0"
users:
id: id(bigint)
email: string unique
name: string
created_at: timestamp default=now
posts:
id: id(bigint)
user_id: references=users.id on_delete=cascade index
title: string
status: string enum=[draft,published] default="draft"
published_at: timestamp?
`);
type DB = InferSchema<typeof schema>;
const db = new Kysely<DB>({ dialect });
const orm = paranorm(db);
const posts = await orm.posts.findMany({
where: {
status: "published",
OR: [{ title: { contains: "Kysely" } }, { title: { startsWith: "SQL" } }],
},
select: { id: true, title: true },
orderBy: [{ published_at: "desc" }],
take: 20,
});paranorm accepts only Kysely<DB>. Models are created lazily through a proxy, while
table names, columns, rows, filters, and operators are inferred from the Kysely database
type. No table list, schema object, or relation metadata is passed to the query wrapper.
Every inferred table exposes:
orm.users.findMany(args?)
orm.users.findFirst(args?)
orm.users.findUnique({ where })
orm.users.create({ data })
orm.users.createMany({ data })
orm.users.update({ where, data })
orm.users.updateMany({ where, data })
orm.users.delete({ where })
orm.users.deleteMany({ where })
orm.users.upsert({ where, create, update })
orm.users.count({ where }?)
orm.users.exists({ where }?)
orm.users.paginate(args)Selections return projected types instead of the full row:
const users = await orm.users.findMany({
select: { id: true, email: true },
});
// Array<{ id: string; email: string }>Writes use Kysely's inferred Insertable and Updateable types. Single-row writes return
the affected row; updateMany and deleteMany return affected counts.
Fields accept direct equality values or type-specific operators:
await orm.users.findMany({
where: {
email: { endsWith: "@example.com" },
name: { notIn: ["Bot", "Deleted"] },
OR: [{ name: { startsWith: "A" } }, { name: { startsWith: "B" } }],
NOT: { email: { contains: "+blocked" } },
},
});Supported operators:
- Strings:
equals,not,in,notIn,contains,startsWith,endsWith - Numbers:
equals,not,in,notIn,lt,lte,gt,gte - Dates:
equals,not,lt,lte,gt,gte - Booleans:
equals,not - Nullable fields:
isNull - Logical composition:
AND,OR,NOT
LIKE wildcards in user values are escaped automatically.
Offset pagination:
const page = await orm.posts.paginate({
orderBy: [{ id: "asc" }],
take: 20,
skip: 40,
});Cursor pagination:
const first = await orm.posts.paginate({
orderBy: [{ published_at: "desc" }, { id: "asc" }],
take: 20,
});
const next = await orm.posts.paginate({
orderBy: [{ published_at: "desc" }, { id: "asc" }],
take: 20,
after: first.pagination.endCursor!,
});The result includes count, hasNext, hasPrevious, startCursor, and endCursor.
Use either API:
import { defineSchema, type InferDatabase, type InferSchema } from "paranorm";
const schema = defineSchema(yamlLiteral);
type DB = InferSchema<typeof schema>;
// Equivalent:
type DBDirect = InferDatabase<typeof yamlLiteral>;Inference supports:
- Generated IDs and columns with defaults
- Nullable insert/select types
- Foreign-key column types
- String enum unions
string, integer, bigint, decimal, boolean, date, timestamp, JSON, and binary columns- Generated auth, API-key, file, and attachment tables
- Kysely's
Selectable,Insertable, andUpdateablehelpers
For IDE extensions that highlight tagged templates, use the exported schema tag directly
or alias it to yaml:
import { schema as yaml } from "paranorm";
function loadSchema() {
return yaml`
_version: "1.0.0"
users:
id: id(bigint)
email: string unique
`;
}The tag uses dedent, so surrounding code indentation is
removed automatically. Interpolations are rejected so the template always contains one complete schema document.
TypeScript does not expose tagged-template contents as a string-literal type, so use
defineSchema(yamlLiteral) when InferSchema compile-time inference is needed. A const
string retains its literal type without an as const assertion.
TypeScript can only infer a string known at compile time. A schema loaded with
Bun.file(...).text() is a runtime string and requires generated declarations instead.
Runtime parsing remains the authoritative schema validator.
See SPEC.md for the complete authoring format.
createMigrator wraps Kysely's native Migrator and derives migration names from each
schema's _version:
import { createMigrator, defineSchema } from "paranorm";
const v1 = defineSchema(`_version: "1.0.0"\nusers:\n id: id\n`);
const v2 = defineSchema(`_version: "2.0.0"\nusers:\n id: id\n email: string?\n`);
const migrator = createMigrator(db, [v1, v2]);
await migrator.plan(); // Pending structured operations
await migrator.validate(); // Ordering and destructive-policy validation
await migrator.sql(); // Compiled SQL without execution
await migrator.migrateToLatest();
await migrator.migrateUp();
await migrator.migrateDown();
await migrator.migrateTo("1.0.0");It returns Kysely's actual Migrator, preserving its methods, locking, migration tables,
and result/error behavior. Inputs may be typed schemas, YAML strings, or { name, content } sources. A third argument accepts both schema options and Kysely migrator options:
const migrator = createMigrator(db, [v1, v2], {
dialect: "postgres",
allowDestructive: false,
allowUnorderedMigrations: false,
migrationTableName: "paranorm_migration",
migrationLockTableName: "paranorm_migration_lock",
});SchemaMigrationProvider and migrateSchemasToLatest remain available as lower-level and
compatibility APIs. Forward destructive changes require allowDestructive: true.
Migration rendering supports postgres, sqlite, mysql, and mssql type/default
variants. Dialect-specific ALTER TABLE, mutation RETURNING, and upsert limitations
still apply.
Schema errors include source locations when parsing strings. Named migration sources are
reported as name:line:column.