kastor0.1.0
Get started

GUIDE 31AdonisJS

kastor with AdonisJS

AdonisJS ships its own ORM, Lucid, and it is a good one. kastor is an option when you want types inferred from one schema instead of model classes, the same API on SQL Server, Oracle or MongoDB, or a data layer shared with other TypeScript services. This guide wires it in the AdonisJS 6 way: env validation, a service module and a provider.

Project structure

AdonisJS 6 organizes code with subpath imports such as #controllers/* and #services/*, validates environment variables at boot, and uses providers for lifecycle work. kastor slots into those three places:

Project
start/env.ts                    DATABASE_URL validation
start/routes.ts                 routes
database/schema.ts              kastor tables and relations
app/services/db.ts              the client (#services/db)
providers/kastor_provider.ts    closes the pool on shutdown
app/controllers/posts_controller.ts
app/validators/post.ts          VineJS input validation
kastor.config.ts                schema: './database/schema.ts'
kastor/migrations/
Terminal
npm install kastor-orm pg
npx kastor-orm init --db postgres

Move the starter schema to database/schema.ts and update the config. If the project already uses Lucid, run npx kastor db pull --out database/schema.ts instead, so kastor describes the tables Lucid migrations created.

Validated environment variables

AdonisJS refuses to boot when a declared variable is missing or malformed, which is exactly what you want for a connection string.

start/env.ts
import { Env } from '@adonisjs/core/env'

export default await Env.create(new URL('../', import.meta.url), {
  NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
  PORT: Env.schema.number(),
  APP_KEY: Env.schema.string(),
  DATABASE_URL: Env.schema.string(),
})

The service module and the provider

The AdonisJS dev server reloads changed modules with hot module replacement. Keeping the client on globalThis in development makes every reload reuse one pool.

app/services/db.ts
import pg from 'pg'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import env from '#start/env'
import { schema, relations } from '../../database/schema'

const create = () =>
  kastor({
    adapter: postgres(new pg.Pool({ connectionString: env.get('DATABASE_URL'), max: 10 })),
    schema,
    relations,
  })

const g = globalThis as unknown as { kastorDb?: ReturnType<typeof create> }
const db = g.kastorDb ?? create()
if (env.get('NODE_ENV') !== 'production') g.kastorDb = db

export default db
export type Db = typeof db
providers/kastor_provider.ts
import type { ApplicationService } from '@adonisjs/core/types'

export default class KastorProvider {
  constructor(protected app: ApplicationService) {}

  async shutdown() {
    const { default: db } = await import('#services/db')
    await db.$disconnect()
  }
}

Register the provider in adonisrc.ts under providers with () => import('./providers/kastor_provider.js'). The shutdown method runs when the app receives a termination signal, after the HTTP server stops.

Controllers, validation and routes

Controllers import the service through its alias. VineJS validates the request body; kastor then receives values that are checked at runtime and at compile time.

app/controllers/posts_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import vine from '@vinejs/vine'
import db from '#services/db'

const createPost = vine.compile(vine.object({ title: vine.string().minLength(3), authorId: vine.number() }))

export default class PostsController {
  async index({ request }: HttpContext) {
    const page = Number(request.input('page', 1))
    const [items, total] = await db.$transaction([
      db.posts.findMany({
        where: { published: true },
        include: { author: { select: { name: true } } },
        orderBy: { id: 'desc' },
        skip: (page - 1) * 20,
        take: 20,
      }),
      db.posts.count({ where: { published: true } }),
    ])
    return { items, total, page }
  }

  async show({ params, response }: HttpContext) {
    const post = await db.posts.findUnique({ where: { id: Number(params.id) }, include: { tags: true } })
    return post ?? response.notFound({ error: 'Post not found' })
  }

  async store({ request, response }: HttpContext) {
    const data: { title: string; authorId: number } = await request.validateUsing(createPost)
    return response.created(await db.posts.create({ data }))
  }
}
start/routes.ts
import router from '@adonisjs/core/services/router'
const PostsController = () => import('#controllers/posts_controller')

router.get('/posts', [PostsController, 'index'])
router.get('/posts/:id', [PostsController, 'show'])
router.post('/posts', [PostsController, 'store'])

The array form of $transaction runs the page query and the count in one transaction, the kastor version of Lucid's paginate(). For cursor pagination, db.posts.findPage returns items and opaque cursors.

Migrations in CI and deploy

Decide who owns schema changes. If Lucid migrations stay in charge, keep node ace migration:run in your deploy and run npx kastor db pull after each new migration to refresh database/schema.ts. If kastor takes over, record the current database once with npx kastor migrate baseline, commit kastor/migrations, and from then on:

.github/workflows/deploy.yml
- run: npm ci
- run: node ace build
- run: npx kastor migrate deploy
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}
- run: ./deploy.sh

The production build in build/ needs DATABASE_URL in its environment like every other variable in start/env.ts.

Running next to Lucid

Many AdonisJS apps will not drop Lucid at once, and they do not have to. Both libraries can use the same database with separate pools. A practical split is to keep Lucid for authentication and the parts that rely on its model hooks, and use kastor for read heavy endpoints that benefit from nested includes in one query, or for code shared with other TypeScript services. Size both pools so their sum stays under the database's connection limit.

Schema ownership needs one rule: only one tool writes migrations. If Lucid stays in charge, kastor follows with db pull after each change. If kastor takes over, stop creating Lucid migrations and let npx kastor migrate dev write the SQL. Mixing the two leads to two histories that disagree about the same tables.

Transactions follow the same split. A Lucid transaction and a kastor transaction are separate connections, so do not expect a write through one to be rolled back by the other. Keep each use case on one library.

Production checklist

Before the first deploy, check these points. Each one prevents a problem that otherwise shows up only under real traffic, usually as exhausted connections, a leaked secret or a schema that does not match the code that is running:

  • DATABASE_URL is declared in start/env.ts.
  • The client is exported from app/services/db.ts and reused across HMR reloads.
  • The provider disconnects the pool on shutdown.
  • Exactly one tool owns migrations.
  • Controllers validate input with VineJS before writing.

Pitfalls

  • Two owners. Running both node ace migration:run and kastor migrate deploy for the same tables leads to conflicting histories. Pick one.
  • The .js extension. AdonisJS 6 is ESM; lazy imports in adonisrc.ts use .js paths even for TypeScript files.
  • Serialization. Controllers return values as JSON; convert bigint keys before returning them.
  • Auth. The AdonisJS auth package works with Lucid models. If you drop Lucid entirely, implement the user provider over kastor or keep a Lucid user model only for auth.

Developers coming from Laravel will recognize much of AdonisJS; the Laravel guide maps Eloquent concepts to kastor.

Questions people ask

Q1Should I replace Lucid with kastor in AdonisJS?

Only if you have a reason: inferred types without model classes, a database Lucid does not cover, or one schema shared with other TypeScript services. Lucid is well integrated with AdonisJS, and both can run side by side on the same database.

Q2Where does the kastor client go in AdonisJS?

In a service module such as app/services/db.ts, imported through the #services/db subpath alias. A provider closes the pool when the app shuts down.

Q3Can Lucid migrations and kastor share a database?

Yes, as long as one tool owns schema changes. Keep running node ace migration:run and refresh the kastor schema with npx kastor db pull, or baseline with npx kastor migrate baseline and let kastor own changes.

Q4Does it work with the AdonisJS dev server and HMR?

Yes. Keep the client on globalThis outside production so hot module reloads reuse the same pool.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.