kastor0.1.0
Get started

GUIDE 21From Rails Active Record

Coming from Rails and Active Record

kastor is a TypeScript ORM that runs in Node.js, Bun, Deno and edge runtimes. It does not run in Ruby and it does not replace Active Record inside a Rails app. This guide maps what you know from Active Record to kastor, for a service that moves to TypeScript or an API that shares the Rails database.

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

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

db 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().

app/models/post.rb
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 }
end
src/db/schema.ts
import { 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 const

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

Terminal
npx kastor migrate baseline --name init
npx kastor migrate dev --name add_slug_to_posts
npx kastor migrate deploy

Step 3: associations, scopes and queries

app/controllers/posts_controller.rb
@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)
src/posts/queries.ts
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 Recordkastor
class Post < ApplicationRecordtable('posts', ...)
belongs_to, has_oneone()
has_manymany()
has_many :through, habtmmany(target, { through: joinTable })
scope :visiblea typed where object
includes, preload, eager_loadinclude
joins(:tags).where(tags: ...)tags: { some: ... }
where.not, ornot or notIn, OR
order, limit, offsetorderBy, take, skip
find, find_by, find_by!findUnique, findFirst, findFirstOrThrow
create, insert_all, upsertcreate, createMany, upsert
update, update_all, destroyupdate, updateMany, delete
increment!, update_counters{ increment: 1 }
count, group(:x).count, sumcount, groupBy, aggregate
ActiveRecord::Base.transactiondb.$transaction(async (tx) => ...)
find_by_sql, connection.exec_querydb.$sql`...`
rails db:migrate, db:rollbacknpx kastor migrate deploy, a new migration that reverts
rails db:seed, db:resetnpx 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

src/db/seed.ts
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_at and updated_at. With defaultNow() and .updatedAt() on those columns, kastor writes do the same.
  • Enums. Integer enums mean nothing without the Ruby mapping. Keep a shared constant like PostStatus above, or move to string enums on both sides.
  • Counter caches. Columns such as comments_count are 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_type plus commentable_id has no database foreign key. Filter on both columns, or use $sql.
  • STI. Single table inheritance keeps a type column; 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 bigint keys, which arrive as bigint in TypeScript. Serialize them with String(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.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.