From two declarations to one
A typed Sequelize model is written twice: once as a TypeScript class with declare fields or InferAttributes, once as the attribute object passed to init(). When you migrate from Sequelize to kastor, both collapse into a single table() call, and the row type comes from it. Queries keep a familiar shape: where, include, order and limit become where, include, orderBy and take.
Nothing happens to your data. kastor reads the existing tables, records them as its starting point, and both libraries share the database while you move code over.
Step 1: install kastor and pull the schema
npm install kastor-orm
npx kastor-orm init --db mysql
npx kastor db pull --out src/db/schema.tsKeep the driver Sequelize already loads: mysql2, pg, mariadb or better-sqlite3. On SQL Server, Sequelize talks to tedious directly while kastor takes a pool from the mssql package, which wraps the same driver. Set DATABASE_URL in the .env that init created. db pull reads tables, keys, indexes and defaults and writes the schema with relations guessed from foreign keys. Here is the before and after for a typical pair of models:
import { DataTypes, Model, type InferAttributes, type InferCreationAttributes, type CreationOptional, type ForeignKey } from 'sequelize'
import { sequelize } from './sequelize'
export class User extends Model<InferAttributes<User>, InferCreationAttributes<User>> {
declare id: CreationOptional<number>
declare email: string
declare name: string | null
declare createdAt: CreationOptional<Date>
}
User.init({
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true },
name: { type: DataTypes.STRING, allowNull: true },
createdAt: DataTypes.DATE,
}, { sequelize, tableName: 'users', updatedAt: false })
export class Post extends Model<InferAttributes<Post>, InferCreationAttributes<Post>> {
declare id: CreationOptional<number>
declare title: string
declare published: CreationOptional<boolean>
declare userId: ForeignKey<number>
}
Post.init({
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
title: { type: DataTypes.STRING(200), allowNull: false },
published: { type: DataTypes.BOOLEAN, defaultValue: false },
}, { sequelize, tableName: 'posts', timestamps: false })
User.hasMany(Post, { foreignKey: 'userId', as: 'posts', onDelete: 'CASCADE' })
Post.belongsTo(User, { foreignKey: 'userId', as: 'author' })import { table, t, index, one, many } from 'kastor-orm'
export const users = table('users', {
id: t.id(),
email: t.string(255).unique(),
name: t.string().nullable(),
createdAt: t.datetime().defaultNow(),
})
export const posts = table('posts', {
id: t.id(),
title: t.string(200),
published: t.boolean().default(false),
userId: t.int().references(() => users.id, { onDelete: 'cascade' }),
}, { indexes: (p) => [index(p.userId)] })
export const schema = { users, posts }
export const relations = ({ users, posts }: typeof schema) => ({
users: { posts: many(posts) },
posts: { author: one(users, { fields: ['userId'], references: ['id'] }) },
})| Sequelize | kastor |
|---|---|
Model.init({ ... }, { tableName }) | table('name', { ... }) |
DataTypes.INTEGER with autoIncrement | t.id() |
STRING(n), TEXT, BOOLEAN, DATE | t.string(n), t.text(), t.boolean(), t.datetime() |
DECIMAL(p, s), JSON, ENUM | t.decimal(p, s), t.json<T>(), t.enum([...]) |
allowNull: true | .nullable() |
defaultValue, DataTypes.NOW | .default(v), .defaultNow() |
field: 'user_id', underscored: true | .map('user_id') |
hasMany, belongsTo, hasOne | many(), one() in relations |
belongsToMany with through | many(target, { through: joinTable }) |
paranoid: true | softDelete: 'deletedAt' |
InferAttributes<User> | InferSelect<typeof users> |
Step 2: baseline instead of replaying history
sequelize-cli stores the names of applied migration files in SequelizeMeta. kastor does not replay those files. It records the current database as one baseline migration, marked as applied:
npx kastor migrate baseline --name init
npx kastor migrate statusDo this in each environment before its first npx kastor migrate deploy. If any environment still runs sequelize.sync() at startup, remove that call first: sync and sync({ alter: true }) change tables behind every migration tool.
Step 3: finders and Op become client calls
import mysql from 'mysql2/promise'
import { kastor } from 'kastor-orm'
import { mysql as mysqlAdapter } from 'kastor-orm/mysql'
import { schema, relations } from './schema'
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 10 })
export const db = kastor({ adapter: mysqlAdapter(pool), schema, relations })import { Op } from 'sequelize'
import { sequelize } from '../sequelize'
import { Post, User } from '../models'
export const feed = (userIds: number[]) =>
Post.findAll({
where: { published: true, userId: { [Op.in]: userIds } },
include: [{ model: User, as: 'author', attributes: ['name'] }],
order: [['id', 'DESC']],
limit: 20,
})
export const search = (q: string) =>
User.findAll({ where: { [Op.or]: [{ email: { [Op.like]: `%${q}%` } }, { name: { [Op.like]: `%${q}%` } }] } })
export const createWithPosts = (email: string, titles: string[]) =>
sequelize.transaction(async (transaction) => {
const user = await User.create({ email }, { transaction })
await Post.bulkCreate(titles.map((title) => ({ title, userId: user.id })), { transaction })
return user
})import { db } from '../db'
export const feed = (userIds: number[]) =>
db.posts.findMany({
where: { published: true, userId: { in: userIds } },
include: { author: { select: { name: true } } },
orderBy: { id: 'desc' },
take: 20,
})
export const search = (q: string) =>
db.users.findMany({ where: { OR: [{ email: { contains: q } }, { name: { contains: q } }] } })
export const createWithPosts = (email: string, titles: string[]) =>
db.users.create({
data: { email, posts: { create: titles.map((title) => ({ title })) } },
include: { posts: true },
})| Sequelize | kastor |
|---|---|
findAll, findOne, findByPk | findMany, findFirst, findUnique |
findAndCountAll | $transaction([findMany, count]) or findPage |
attributes: [...] | select: { field: true } |
order: [['id', 'DESC']], limit, offset | orderBy: { id: 'desc' }, take, skip |
Op.eq, Op.ne, Op.in, Op.notIn | equals, not, in, notIn |
Op.gt, Op.gte, Op.lt, Op.lte, Op.between | gt, gte, lt, lte |
Op.like, Op.iLike, Op.startsWith | contains, mode: 'insensitive', startsWith |
Op.and, Op.or, Op.not | AND, OR, NOT |
create, bulkCreate, upsert | create, createMany, upsert |
Model.update(values, { where }), destroy | updateMany, deleteMany |
instance.increment('views') | update with views: { increment: 1 } |
sequelize.transaction(fn) | db.$transaction(fn) |
sequelize.query(sql, { replacements }) | db.$sql`...` |
There are no model instances in kastor. A row is a plain object, so user.save() and user.reload() become explicit update and findUnique calls. That makes every round trip visible in the code, and rows serialize to JSON without toJSON() or raw: true.
Step 4: scopes and hooks
A Sequelize scope is a named piece of query options. In kastor the same idea is a typed constant you spread into where:
import { db } from '../db'
// Was: Post.addScope('published', { where: { published: true } })
const published = { published: true } as const
export const recentPublished = (userId: number) =>
db.posts.findMany({ where: { ...published, userId }, orderBy: { id: 'desc' }, take: 10 })Hooks such as beforeCreate become code in the function that writes. Hashing a password, normalizing an email or setting a slug belongs next to the create call, where a reader sees it. Generated values can be JavaScript defaults on the column: t.uuid().primaryKey().defaultRandom() or .default(() => makeSlug()).
Step 5: switch CI and remove Sequelize
Replace npx sequelize-cli db:migrate with npx kastor migrate deploy in the deploy pipeline. New schema changes come from editing src/db/schema.ts and running npx kastor migrate dev --name what_changed, which writes a readable SQL file you commit. When nothing imports the models, uninstall sequelize and sequelize-cli and delete .sequelizerc.
Pitfalls to check
- Automatic timestamps. Sequelize adds
createdAtandupdatedAtunless told not to, and fills them itself. In kastor, declare them ast.datetime().defaultNow()andt.datetime().updatedAt(); kastor fills the second on every create and update. - Pluralized table names. Without
tableName, Sequelize pluralized model names.db pulluses the real table names, so the export forUsersisusers. - Case sensitivity of like.
Op.likeis case insensitive on MySQL with the default collation and case sensitive on PostgreSQL. kastor'scontainsis case sensitive everywhere unless you addmode: 'insensitive', so pick the behavior you meant. - Decimal values. Sequelize returned decimals as strings on most dialects, and so does kastor. Bigint columns come back as
bigint. - Raw queries. Replacements written as
:namebecome template values indb.$sql, which are always sent as parameters.
Questions people ask
Q1Does kastor support Sequelize style include?
Yes, with the same word. include: [{ model: Post }] becomes include: { posts: true }, and nested includes take their own where, orderBy and take. kastor loads the whole tree in one statement per call with JSON aggregation instead of a wide join.
Q2What replaces Op.or, Op.in and Op.like?
Plain keys in the where object: OR: [...], { in: [...] } and contains, startsWith or endsWith, with mode: 'insensitive' for ILIKE style matching on every database.
Q3What happens to the SequelizeMeta table?
Nothing. kastor keeps its own history in _kastor_migrations. After npx kastor migrate baseline --name init, the old table is no longer read and can be dropped once Sequelize is gone.
Q4Are there model hooks in kastor?
No. Hooks run hidden code on every write, which is hard to follow in a typed codebase. Put the logic in the function that writes, use JavaScript defaults for generated values, and the log option of the client for auditing statements.
Q5Can I keep paranoid models?
Yes. A table declared with softDelete: 'deletedAt' behaves like a paranoid model: delete sets the timestamp, reads skip deleted rows, and restore and hardDelete do the rest.