kastor0.1.0
Get started

GUIDE 16From Sequelize

Migrate from Sequelize to kastor

Sequelize grew up in JavaScript, and typing it means declaring every attribute twice. kastor infers row types from one TypeScript schema, keeps include and where as the query vocabulary, and takes over the database Sequelize created without moving a row.

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

Terminal
npm install kastor-orm
npx kastor-orm init --db mysql
npx kastor db pull --out src/db/schema.ts

Keep 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:

Sequelize: src/models.ts (before)
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' })
src/db/schema.ts
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'] }) },
})
Sequelizekastor
Model.init({ ... }, { tableName })table('name', { ... })
DataTypes.INTEGER with autoIncrementt.id()
STRING(n), TEXT, BOOLEAN, DATEt.string(n), t.text(), t.boolean(), t.datetime()
DECIMAL(p, s), JSON, ENUMt.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, hasOnemany(), one() in relations
belongsToMany with throughmany(target, { through: joinTable })
paranoid: truesoftDelete: '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:

Terminal
npx kastor migrate baseline --name init
npx kastor migrate status

Do 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

src/db/index.ts
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 })
Sequelize: src/posts/service.ts (before)
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
  })
src/posts/service.ts
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 },
  })
Sequelizekastor
findAll, findOne, findByPkfindMany, findFirst, findUnique
findAndCountAll$transaction([findMany, count]) or findPage
attributes: [...]select: { field: true }
order: [['id', 'DESC']], limit, offsetorderBy: { id: 'desc' }, take, skip
Op.eq, Op.ne, Op.in, Op.notInequals, not, in, notIn
Op.gt, Op.gte, Op.lt, Op.lte, Op.betweengt, gte, lt, lte
Op.like, Op.iLike, Op.startsWithcontains, mode: 'insensitive', startsWith
Op.and, Op.or, Op.notAND, OR, NOT
create, bulkCreate, upsertcreate, createMany, upsert
Model.update(values, { where }), destroyupdateMany, 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:

src/posts/scopes.ts
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 createdAt and updatedAt unless told not to, and fills them itself. In kastor, declare them as t.datetime().defaultNow() and t.datetime().updatedAt(); kastor fills the second on every create and update.
  • Pluralized table names. Without tableName, Sequelize pluralized model names. db pull uses the real table names, so the export for Users is users.
  • Case sensitivity of like. Op.like is case insensitive on MySQL with the default collation and case sensitive on PostgreSQL. kastor's contains is case sensitive everywhere unless you add mode: '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 :name become template values in db.$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.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.