Ajo Kit
Database
SQLite through Kysely, migrations, seeds and data on disk.
Kit stores data in SQLite and queries it through Kysely, a typed SQL query builder. One shared connection serves the whole process. During development, tests and CLI commands the connection comes from better-sqlite3 on Node; in production it comes from the engine’s own SQLite module. Your code uses the same ajo-kit/database API in both places.
Connect
Describe your tables once, connect at module load, and export a typed accessor. This mirrors the official starter’s src/database.ts.
import { connect, db as database, type Generated, type Selectable } from 'ajo-kit/database'
import { env } from 'ajo-kit/platform'
interface Notes {
id: Generated<number>
title: string
body: string
pinned: Generated<number>
created: Generated<string>
}
export interface Database {
notes: Notes
}
export type Note = Pick<Selectable<Notes>, 'id' | 'title' | 'created'>
connect(env('DATABASE_PATH') ?? './database.sqlite')
export const db = () => database<Database>()Generated marks columns the database fills in, so inserts may omit them. env() reads the environment on both hosts. This module must never reach the browser, so add it to the client guard in vite.config.ts:
plugins: [...kit({ guard: [/\/src\/database/] })]| Export | Purpose |
|---|---|
connect(path = './database.sqlite') | Opens the shared connection. Connecting again to the same path does nothing; a different path throws. |
db<T>() | Returns the shared Kysely<T> instance, opening the default path on first use if nothing is connected. |
close() | Destroys the Kysely instance and closes SQLite. |
sql | Kysely’s tag for raw SQL fragments, such as sql`CURRENT_TIMESTAMP`. |
| Types | Kysely, Generated, Selectable and Insertable. |
Query
Loaders read, actions write. Name the columns you need with select([...]) rather than selectAll(): the route payload is sent to the browser, and an explicit list keeps new or private columns from leaking into it. Bound lists with limit().
import type { Request } from 'ajo-kit'
import { db } from '../database'
export async function page(req: Request) {
req.track?.('notes')
const notes = await db()
.selectFrom('notes')
.select(['id', 'title', 'created'])
.orderBy('id', 'desc')
.limit(50)
.execute()
return { notes }
}When one logical change takes several statements, run them in a transaction so they land together or not at all. Emit topics only after it commits, so live pages never reload data that might still roll back.
import type { ActionContext, Request, Response } from 'ajo-kit'
import { number, object, parse } from 'ajo-kit/validate'
import { db } from '../database'
const Pin = object({ id: number() })
export const actions = {
async pin(req: Request, _res: Response, action: ActionContext) {
const { id } = parse(Pin, req.body)
await db().transaction().execute(async trx => {
await trx.updateTable('notes').set({ pinned: 0 }).where('pinned', '=', 1).execute()
await trx.updateTable('notes').set({ pinned: 1 }).where('id', '=', id).execute()
})
action.emit('notes')
return { ok: true }
},
}Connection settings
Kit configures every connection when it opens. The two hosts differ in one durability setting.
| Setting | Node (development, tests, CLI) | Ajo Engine |
|---|---|---|
| Journal | WAL | WAL for writable file databases |
| Foreign keys | On | On |
| Busy timeout | 5000 ms | 5000 ms |
synchronous | NORMAL | FULL, so a committed transaction survives power loss |
| Temporary storage | SQLite default | In memory, so nothing spills outside the declared roots |
Migrations
Schema changes live in numbered files under db/migrations. Each one exports up() and down().
Create the next file. Kit picks the number and turns the name into lowercase words joined by underscores.
Terminal pnpm kit migrate create notesWrite both directions.
db/migrations/0001_notes.ts import { sql, type Kysely } from 'ajo-kit/database' export async function up(db: Kysely<any>): Promise<void> { await db.schema .createTable('notes') .addColumn('id', 'integer', column => column.primaryKey()) .addColumn('title', 'text', column => column.notNull()) .addColumn('body', 'text', column => column.notNull().defaultTo('')) .addColumn('pinned', 'integer', column => column.notNull().defaultTo(0)) .addColumn('created', 'text', column => column.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)) .execute() } export async function down(db: Kysely<any>): Promise<void> { await db.schema.dropTable('notes').execute() }Apply it, and check what has run.
Terminal pnpm kit migrate up pnpm kit migrate status
| Command | Effect |
|---|---|
kit migrate create <name> | Writes db/migrations/NNNN_name.ts with empty up() and down(). |
kit migrate up | Runs every pending migration. |
kit migrate down | Rolls back the single most recently executed migration, whichever source it came from. |
kit migrate status | Lists each migration as executed (✓) or pending (○). |
up, down and status use ./database.sqlite unless you pass -d or --database. They do not read DATABASE_PATH, so if your application uses another file, pass the same path: pnpm kit migrate up -d ./notes.sqlite.
- File names are a four-digit number and an underscore, then lowercase letters or digits in words joined by underscores, with a
.ts,.js,.mts,.mjs,.ctsor.cjsextension. - The CLI runs TypeScript migrations with Node’s built-in type stripping, so they must use erasable syntax only: no
enum,namespaceor parameter properties. - A gap in the numbering, a duplicate name, an invalid file name or a missing
up()ordown()stops the command before any migration runs. - Treat a migration as immutable once it has run anywhere that matters. Change the schema by adding the next one.
Sources and identities
Kit combines your migrations with those of installed plugins. A plugin is an ajo-* package whose package.json has a kit.migrations folder; plugin discovery covers the rules. Each folder numbers its own files from 0001 without gaps, so a plugin and your application may both have a 0001_initial. The history stores qualified names:
| Source | Stored as |
|---|---|
| Your application | project/0001_notes |
The ajo-kit-auth plugin | plugin/ajo-kit-auth/0001_initial |
All sources share one Kysely migrator and one history table. Pending migrations run in order of their qualified names, so on a fresh database plugin migrations run before yours, and your tables can reference theirs. A plugin may later add its next migration after one of yours has run. status fails when the history names a migration no source provides any longer, rather than guessing.
✓ plugin/ajo-kit-auth/0001_initial
✓ plugin/ajo-kit-auth/0002_passkeys
○ project/0001_notesSeeds
kit seed fills a development database with sample data. It imports every .ts file in db/seeds in file-name order and calls its exported seed(db) with the Kysely instance. Like the migration commands, it takes -d and defaults to ./database.sqlite.
import type { Kysely } from 'ajo-kit/database'
import type { Database } from '../../src/database'
export async function seed(db: Kysely<Database>) {
await db.deleteFrom('notes').execute()
await db.insertInto('notes').values([
{ title: 'Groceries', body: 'Garlic, bread, olive oil.' },
{ title: 'Ideas', body: 'A journal that updates across tabs.' },
]).execute()
}Seeds run on Node only and are not part of the production build. Import application types with import type, as above: a value import would run the connect() call in src/database.ts, which throws when it names a different file than -d.
Data on disk in production
The supported production topology is one engine process with one SQLite file on persistent local disk. The engine confines filesystem and SQLite access to the roots the application declares, so declare the data directory in package.json:
{
"kit": {
"engine": {
"fs": { "roots": ["/ajo/data"] }
}
}
}Create that writable directory in the runtime, then start the engine with AJO_DATA=/ajo/data and a relative DATABASE_PATH such as notes.sqlite. The file then lives at /ajo/data/notes.sqlite.
- The data path must sit inside a declared root. Setting
AJO_DATAalone grants nothing. - On the engine,
DATABASE_PATHresolves beneathAJO_DATA. Absolute paths and..segments are rejected, and a file database without a data root fails.:memory:is accepted as is. - When the build uses the database, the artifact requires a data root, and the engine refuses to start without an available
AJO_DATA. - When the application uses the database, the engine connects to
DATABASE_PATH(default./database.sqlite) at startup and runs the compiled migrations before it runs the bootstrap hook or accepts a request. Read the same variable in your ownconnect()call, because connecting to a different path throws.
Build and configuration lists every kit.engine field, and Seal and run shows the full engine command.
Backups
- Live updatesEmit topics after commits so pages refresh.
- Build and configurationDeclare engine authority and plugins.
- Seal and runRun the sealed application with its data root.
Source of truth: ajo-kit README