Where kastor fits for a Rails team
Active Record is woven into Rails and written in Ruby. kastor is a TypeScript library that runs in JavaScript runtimes: Node.js, Bun, Deno and edge platforms such as Cloudflare Workers. There is no gem, and kastor cannot be called from a Rails app.
What it offers someone coming from Rails Active Record is a familiar shape in TypeScript: tables with associations, scopes as reusable conditions, eager loading, schema migrations and seeds. Two paths lead here:
- Path A: moving to TypeScript. A service or a whole backend moves to Node.js or Bun, and the team wants the Active Record habits to carry over.
- Path B: TypeScript next to Rails. Rails keeps running, and a TypeScript API, worker or Next.js app uses the same PostgreSQL or MySQL database.
Step 1: pull the schema Rails built
npm install kastor-orm pg
npx kastor-orm init --db postgres
npx kastor db pull --out src/db/schema.tsdb pull plays the role of db/schema.rb in reverse: it reads the live database and writes TypeScript. Rails tables are plural and snake case, so the exports come out as users, posts and postsTags style names, and columns keep their database names through .map().
class Post < ApplicationRecord
belongs_to :author, class_name: "User", foreign_key: :user_id
has_and_belongs_to_many :tags
has_many :comments, dependent: :destroy
enum :status, { draft: 0, published: 1, archived: 2 }
scope :visible, -> { where(status: :published) }
scope :recent, -> { order(created_at: :desc) }
validates :title, presence: true, length: { maximum: 200 }
endimport { table, t, one, many } from 'kastor-orm'
export const users = table('users', {
id: t.bigId(),
email: t.string().unique(),
name: t.string().nullable(),
createdAt: t.datetime().map('created_at').defaultNow(),
updatedAt: t.datetime().map('updated_at').updatedAt(),
})
export const posts = table('posts', {
id: t.bigId(),
userId: t.bigint().map('user_id').references(() => users.id),
title: t.string(200),
// Rails integer enum: 0 draft, 1 published, 2 archived
status: t.int().default(0),
createdAt: t.datetime().map('created_at').defaultNow(),
updatedAt: t.datetime().map('updated_at').updatedAt(),
})
export const tags = table('tags', {
id: t.bigId(),
name: t.string().unique(),
})
// has_and_belongs_to_many join table, no primary key in Rails
export const postsTags = table('posts_tags', {
postId: t.bigint().map('post_id').references(() => posts.id),
tagId: t.bigint().map('tag_id').references(() => tags.id),
}, { primaryKey: (c) => [c.postId, c.tagId] })
export const comments = table('comments', {
id: t.bigId(),
postId: t.bigint().map('post_id').references(() => posts.id),
body: t.text(),
createdAt: t.datetime().map('created_at').defaultNow(),
})
export const schema = { users, posts, tags, postsTags, comments }
export const relations = ({ users, posts, tags, postsTags, comments }: typeof schema) => ({
users: { posts: many(posts) },
posts: {
author: one(users, { fields: ['userId'], references: ['id'] }),
tags: many(tags, { through: postsTags }),
comments: many(comments),
},
comments: { post: one(posts, { fields: ['postId'], references: ['id'] }) },
})
export const PostStatus = { draft: 0, published: 1, archived: 2 } as constRails has_and_belongs_to_many join tables have no primary key. kastor declares one over both columns in the schema; if you let kastor own migrations later, add the matching constraint, which also stops duplicate pairs.
Step 2: pick the migration owner
Rails tracks applied versions in schema_migrations, kastor in _kastor_migrations. While Rails is the main app, keep rails db:migrate as the only way to change the schema and run npx kastor db pull after each migration. When TypeScript takes over, record a baseline and switch:
npx kastor migrate baseline --name init
npx kastor migrate dev --name add_slug_to_posts
npx kastor migrate deployStep 3: associations, scopes and queries
@posts = Post.visible.recent
.joins(:tags).where(tags: { name: "ruby" })
.includes(:author, :tags)
.where.not(user_id: blocked_ids)
.limit(20)
Post.where(id: post_id).update_counters(views: 1)import { db } from '../db'
import { PostStatus } from '../db/schema'
// scope :visible and scope :recent
const visible = { status: PostStatus.published }
const recent = { createdAt: 'desc' } as const
export const rubyPosts = (blockedIds: bigint[]) =>
db.posts.findMany({
where: { ...visible, tags: { some: { name: 'ruby' } }, userId: { notIn: blockedIds } },
include: { author: true, tags: true },
orderBy: recent,
take: 20,
})
export const archive = (id: bigint) =>
db.posts.update({ where: { id }, data: { status: PostStatus.archived } })| Active Record | kastor |
|---|---|
class Post < ApplicationRecord | table('posts', ...) |
belongs_to, has_one | one() |
has_many | many() |
has_many :through, habtm | many(target, { through: joinTable }) |
scope :visible | a typed where object |
includes, preload, eager_load | include |
joins(:tags).where(tags: ...) | tags: { some: ... } |
where.not, or | not or notIn, OR |
order, limit, offset | orderBy, take, skip |
find, find_by, find_by! | findUnique, findFirst, findFirstOrThrow |
create, insert_all, upsert | create, createMany, upsert |
update, update_all, destroy | update, updateMany, delete |
increment!, update_counters | { increment: 1 } |
count, group(:x).count, sum | count, groupBy, aggregate |
ActiveRecord::Base.transaction | db.$transaction(async (tx) => ...) |
find_by_sql, connection.exec_query | db.$sql`...` |
rails db:migrate, db:rollback | npx kastor migrate deploy, a new migration that reverts |
rails db:seed, db:reset | npx kastor db seed, migrate reset |
Rails validations run in Ruby before a save. kastor checks types at compile time and leaves value rules (presence, length, format) to your input validation layer and to database constraints. Validate request bodies at the API boundary, and let t.string(200) and NOT NULL in the database back that up; violations come back as KastorError with codes such as NOT_NULL_VIOLATION and a hint.
Step 4: seeds, factories and callbacks
import { defineSeed, factory } from 'kastor-orm'
import type { db as client } from './index'
export default defineSeed<typeof client>(async (db) => {
const ruby = await db.tags.upsert({ where: { name: 'ruby' }, create: { name: 'ruby' }, update: {} })
const users = factory(db.users, ({ seq }) => ({ email: `user${seq}@example.com`, name: `User ${seq}` }))
for (const user of await users.createMany(3)) {
await db.posts.create({
data: { userId: user.id, title: `Hello from ${user.name}`, status: 1, tags: { connect: { id: ruby.id } } },
})
}
})Callbacks such as before_save and after_commit have no counterpart. Put them in the function that writes, after the await for after commit work, so readers see every side effect. dependent: :destroy runs in Ruby; in kastor, use onDelete: 'cascade' on the reference when kastor owns migrations, or delete children explicitly inside a $transaction.
Path B in practice: sharing the database with Rails
- Timestamps. Rails sets
created_atandupdated_at. WithdefaultNow()and.updatedAt()on those columns, kastor writes do the same. - Enums. Integer enums mean nothing without the Ruby mapping. Keep a shared constant like
PostStatusabove, or move to string enums on both sides. - Counter caches. Columns such as
comments_countare maintained by Rails callbacks. Update them in the same transaction when TypeScript inserts or deletes children. - Sessions and Devise. Rails cookies are encrypted with
secret_key_base. Share authentication through tokens or an auth service rather than decoding cookies in Node. - Connections. Puma threads and the Node pool both hold connections, so size them together.
Pitfalls to check
- Polymorphic associations.
commentable_typepluscommentable_idhas no database foreign key. Filter on both columns, or use$sql. - STI. Single table inheritance keeps a
typecolumn; model it as a string column and filter on it. - Default scopes. kastor applies nothing implicitly except soft delete filtering on tables you declare with
softDelete. Gems like paranoia or discard map to that option. - Big integer ids. Rails uses
bigintkeys, which arrive asbigintin TypeScript. Serialize them withString(id)in JSON responses.
Continue with the Hono or Express guide for the API itself, or the migration hub for the other paths.
Questions people ask
Q1Can I use kastor in a Ruby or Rails project?
No. kastor runs only in JavaScript runtimes such as Node.js, Bun, Deno and Cloudflare Workers. Rails keeps Active Record. kastor is for TypeScript code that moves out of Rails or runs next to it on the same database.
Q2What is the kastor equivalent of includes and preload?
include. Post.includes(:author, :tags) becomes db.posts.findMany({ include: { author: true, tags: true } }), loaded in one statement per call with JSON aggregation, so there is no preload versus eager_load choice to make.
Q3How do Active Record scopes translate?
As typed where objects or small functions that return one. scope :published, -> { where(published: true) } becomes a constant you spread into where, and chaining scopes becomes combining objects or using AND.
Q4Can Rails and a Node.js service share a PostgreSQL database?
Yes. Run npx kastor db pull, keep one side in charge of migrations, and follow Rails conventions on the TypeScript side: set updated_at on writes and keep enum integers or strings in the same format Rails uses.
Q5What replaces db/seeds.rb and FactoryBot?
A seed file with defineSeed run by npx kastor db seed, and factory(db.users, ({ seq }) => data) for test records.