kastor0.1.0
Get started

GUIDE 15From TypeORM

Migrate from TypeORM to kastor

TypeORM describes tables with decorated classes and queries them through repositories and a query builder. kastor replaces the classes with a plain TypeScript schema whose types are inferred, and the repositories with one typed client. Your tables and rows stay exactly where they are.

Why the move is mostly deletion

A TypeORM project carries three layers of description for the same table: the entity class with its decorators, the migration files, and the database itself. When you migrate from TypeORM to kastor, the class layer goes away. The schema is a plain TypeScript value, the row type is inferred from it, and the client gives you one typed accessor per table instead of a repository per entity.

The database is untouched. kastor reads the tables TypeORM created, records them as its starting point, and from then on generates readable SQL migrations from changes to the TypeScript schema. While you move services one by one, both libraries talk to the same database.

Step 1: install kastor and freeze synchronize

Before anything else, check that synchronize is false in every DataSource. With synchronize on, TypeORM alters tables at startup to match the entities, which would undo changes kastor makes later.

Terminal
npm install kastor-orm
npx kastor-orm init --db postgres

Keep the driver you already have (pg, mysql2, mariadb, better-sqlite3, mssql, oracledb or mongodb). Set DATABASE_URL in the .env that init wrote to the database the DataSource points at.

Step 2: entities become a TypeScript schema

Run db pull to get the schema from the live database rather than translating decorators by hand. It reads column types, nullability, defaults, unique constraints, indexes and foreign keys, and guesses relations from the keys.

Terminal
npx kastor db pull --out src/db/schema.ts
TypeORM: src/entities/User.ts (before)
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany, ManyToOne, JoinColumn, Index } from 'typeorm'

@Entity('users')
export class User {
  @PrimaryGeneratedColumn() id!: number
  @Column({ length: 255, unique: true }) email!: string
  @Column({ type: 'text', nullable: true }) name!: string | null
  @Column({ type: 'enum', enum: ['user', 'admin'], default: 'user' }) role!: 'user' | 'admin'
  @CreateDateColumn({ name: 'created_at' }) createdAt!: Date
  @OneToMany(() => Post, (p) => p.author) posts!: Post[]
}

@Entity('posts')
export class Post {
  @PrimaryGeneratedColumn() id!: number
  @Column({ length: 200 }) title!: string
  @Column({ default: false }) published!: boolean
  @Index() @Column({ name: 'author_id' }) authorId!: number
  @ManyToOne(() => User, (u) => u.posts, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'author_id' }) author!: User
}
src/db/schema.ts
import { table, t, index, one, many } from 'kastor-orm'
import type { InferSelect, InferInsert } from 'kastor-orm'

export const users = table('users', {
  id: t.id(),
  email: t.string(255).unique(),
  name: t.text().nullable(),
  role: t.enum(['user', 'admin']).default('user'),
  createdAt: t.datetime().map('created_at').defaultNow(),
})

export const posts = table('posts', {
  id: t.id(),
  title: t.string(200),
  published: t.boolean().default(false),
  authorId: t.int().map('author_id').references(() => users.id, { onDelete: 'cascade' }),
}, { indexes: (p) => [index(p.authorId)] })

export const schema = { users, posts }

export const relations = ({ users, posts }: typeof schema) => ({
  users: { posts: many(posts) },
  posts: { author: one(users, { fields: ['authorId'], references: ['id'] }) },
})

// What the entity classes were used for, as types.
export type User = InferSelect<typeof users>
export type NewUser = InferInsert<typeof users>
TypeORMkastor
@Entity('users') classtable('users', ...)
@PrimaryGeneratedColumn()t.id()
@PrimaryGeneratedColumn('uuid')t.uuid().primaryKey().defaultRandom()
@Column({ nullable: true }).nullable()
@Column({ name: 'author_id' }).map('author_id')
@CreateDateColumn()t.datetime().defaultNow()
@UpdateDateColumn()t.datetime().updatedAt()
@DeleteDateColumn()softDelete: 'deletedAt' table option
@OneToMany, @ManyToOnemany(), one() in relations
@ManyToMany with @JoinTablea join table plus many(tags, { through: postTags })
@Index(), @Unique()index(), uniqueIndex(), .unique()

Step 3: baseline the existing database

TypeORM tracks applied migrations in its migrations table. kastor keeps its own history in _kastor_migrations, so it needs to know where to start. A baseline writes one migration that matches the current database and marks it as applied without running it:

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

Run the baseline once in every environment before the first npx kastor migrate deploy there. Until TypeORM is gone, make schema changes with only one tool: either keep writing TypeORM migrations and run db pull afterwards, or switch to npx kastor migrate dev right away and stop generating TypeORM migrations.

Step 4: repositories and query builders become client calls

Create the client once, next to the schema. Each table key becomes an accessor: dataSource.getRepository(User) turns into db.users.

src/db/index.ts
import pg from 'pg'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { schema, relations } from './schema'

export const db = kastor({
  adapter: postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5 })),
  schema,
  relations,
})
TypeORM: src/posts/service.ts (before)
import { In, ILike } from 'typeorm'
import { dataSource } from '../data-source'
import { Post } from '../entities/Post'
import { User } from '../entities/User'

const posts = dataSource.getRepository(Post)

export const feed = (authorIds: number[]) =>
  posts.find({
    where: { published: true, authorId: In(authorIds) },
    relations: { author: true },
    order: { id: 'DESC' },
    take: 20,
  })

export const search = (q: string) =>
  dataSource.getRepository(User).find({ where: { email: ILike(`%${q}%`) } })

export const transfer = (postId: number, toUserId: number) =>
  dataSource.transaction(async (manager) => {
    const post = await manager.findOneByOrFail(Post, { id: postId })
    post.authorId = toUserId
    return manager.save(post)
  })

export const busiest = () =>
  posts.createQueryBuilder('p')
    .select('p.author_id', 'authorId').addSelect('COUNT(*)', 'n')
    .groupBy('p.author_id').orderBy('n', 'DESC').limit(5).getRawMany()
src/posts/service.ts
import { db } from '../db'

export const feed = (authorIds: number[]) =>
  db.posts.findMany({
    where: { published: true, authorId: { in: authorIds } },
    include: { author: true },
    orderBy: { id: 'desc' },
    take: 20,
  })

export const search = (q: string) =>
  db.users.findMany({ where: { email: { contains: q, mode: 'insensitive' } } })

export const transfer = (postId: number, toUserId: number) =>
  db.$transaction(async (tx) => {
    await tx.posts.findUniqueOrThrow({ where: { id: postId } })
    return tx.posts.update({ where: { id: postId }, data: { authorId: toUserId } })
  })

export const busiest = () =>
  db.posts.groupBy({
    by: 'authorId',
    _count: { _all: true },
    orderBy: { _count: { _all: 'desc' } },
    take: 5,
  })
TypeORMkastor
repo.find({ where, relations, order, take, skip })db.x.findMany({ where, include, orderBy, take, skip })
repo.findOneBy, findOneByOrFailfindUnique, findUniqueOrThrow, findFirst
In, Not, LessThan, MoreThan, Betweenin, not, lt, gt, gte with lte
Like, ILike, IsNullcontains with mode, null
repo.save(entity)create, update or upsert, said explicitly
repo.insert, update, deletecreateMany, updateMany, deleteMany
repo.softDelete, restoredelete and restore on a soft delete table
repo.count, incrementcount, update with { increment: 1 }
dataSource.transaction(fn)db.$transaction(fn)
dataSource.query(sql, params)db.$sql`...` with interpolated values
createQueryBuilder joinsinclude, relation filters, groupBy, or $sql

The biggest behavioral change is save(). TypeORM decides between insert and update by loading the entity first. kastor asks you to say which one you mean, which removes a hidden read and makes the SQL predictable. If you relied on cascading saves of related entities, use nested writes: db.users.create({ data: { email, posts: { create: [...] } } }) inserts the user and the posts in one transaction.

Step 5: remove TypeORM

When no service imports a repository any more, delete the entity classes, the DataSource file and the TypeORM CLI scripts. If nothing else needs decorators, remove reflect-metadata and turn off emitDecoratorMetadata. In CI, replace typeorm migration:run with npx kastor migrate deploy. Use the NestJS guide if your project injects repositories through TypeOrmModule.

Pitfalls to check

  • Lazy and eager relations. Code that awaited user.posts as a promise, or expected eager relations to be present, needs an explicit include. The types tell you: a row without the include has no posts property.
  • Column naming strategies. A custom naming strategy produced the database names; db pull writes them as .map() calls so your TypeScript names stay camelCase.
  • Decimal and bigint. kastor returns decimals as strings and bigint columns as bigint, never as lossy numbers. TypeORM returned strings for both on most drivers, so check arithmetic.
  • Listeners. @BeforeInsert and subscribers do not exist in kastor. Move that logic into the function that writes, and use a JavaScript default such as .default(() => crypto.randomUUID()) for generated values.
  • Enums on MySQL and SQL Server. TypeORM may have stored enums as plain strings on databases without a native enum. db pull reports what the database really has; switch the column to t.enum([...]) only if you also want the constraint.

Questions people ask

Q1Do I keep my entity classes with kastor?

No. Tables are declared with table() and row types come from InferSelect<typeof users>. If other code relies on class instances, map rows into those classes at the edge, but most projects only used entities as types, which the inferred types replace.

Q2What happens to the TypeORM migrations table?

kastor does not read the migrations table. Run npx kastor migrate baseline --name init to record the current database as the starting point; the old table stays in place and can be dropped after the move.

Q3Is there an equivalent of eager relations?

kastor never loads relations implicitly. Ask for them with include on the query that needs them. They load in one statement per call, so explicit includes cost less than eager or lazy relations that fire extra queries.

Q4Do I still need reflect-metadata and emitDecoratorMetadata?

Not for kastor. It uses no decorators and no runtime type metadata. Frameworks such as NestJS may still need them for their own dependency injection.

Q5How do I replace entity listeners and subscribers?

Call the logic from the function that performs the write, or wrap the write in a small service. For logging and timing, the log option of kastor() receives every statement with its duration.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.