A minimal, schema-based declarative migration tool for Node.js ecosystem
kyrage (kirΔju) automatically generates and applies database migrations by comparing your TypeScript schema definitions with your actual database state. No more writing migration files by hand!
Traditional database migrations require manually writing up/down migration files every time you change your schema. This is error-prone and time-consuming.
kyrage takes a different approach:
- βοΈ Define your desired schema in TypeScript
- π kyrage compares it with your actual database
- π Automatically generates the necessary migrations
- β Apply migrations with a single command
This is a style of managing database schema that is called as Versioned Migration Authoring by Atlas.
# Install globally
npm install -g @izumisy/kyrage
# Or use with npx
npx @izumisy/kyrage --helpCreate a kyrage.config.ts file in your project root:
import { defineConfig } from "@izumisy/kyrage";
export default defineConfig({
database: {
dialect: "postgres",
connectionString: "postgres://postgres:password@localhost:5432/mydb",
},
});Create your table definitions (e.g., in schema.ts):
import { column as c, defineTable as t } from "@izumisy/kyrage";
export const members = t(
"members",
{
id: c("uuid", { primaryKey: true }),
email: c("text", { unique: true, notNull: true }),
name: c("text", { unique: true }),
age: c("integer"),
createdAt: c("timestamptz", { defaultSql: "now()" }),
},
(t) => [
t.index(["name", "email"], {
unique: true,
}),
]
);
export const posts = t(
"posts",
{
id: c("uuid"),
author_id: c("uuid"),
slug: c("text", { notNull: true }),
title: c("text"),
content: c("text", { notNull: true }),
},
(t) => [
t.primaryKey(["id", "author_id"]),
t.unique(["author_id", "slug"], {
name: "unique_author_slug",
}),
t.reference("author_id", members, "id", {
onDelete: "cascade",
name: "posts_author_fk"
}),
]
);Add your schema to the configuration:
import { defineConfig } from "@izumisy/kyrage";
+import { members, posts } from "./schema";
export default defineConfig({
database: {
dialect: "postgres",
connectionString: "postgres://postgres:password@localhost:5432/mydb",
},
+ tables: [members, posts],
});Compare your schema with the database and generate a migration:
$ kyrage generate
-- create_table: members
-> column: id ({"type":"uuid","primaryKey":true,"notNull":true,"unique":false})
-> column: email ({"type":"text","primaryKey":false,"notNull":true,"unique":true})
-> column: name ({"type":"text","primaryKey":false,"notNull":false,"unique":true})
-> column: age ({"type":"integer","primaryKey":false,"notNull":false,"unique":false})
-> column: createdAt ({"type":"timestamptz","primaryKey":false,"notNull":false,"unique":false,"defaultSql":"now()"})
-- create_table: posts
-> column: id ({"type":"uuid","primaryKey":false,"notNull":false,"unique":false})
-> column: author_id ({"type":"uuid","primaryKey":false,"notNull":false,"unique":false})
-> column: slug ({"type":"text","primaryKey":false,"notNull":true,"unique":false})
-> column: title ({"type":"text","primaryKey":false,"notNull":false,"unique":false})
-> column: content ({"type":"text","primaryKey":false,"notNull":true,"unique":false})
-- create_index: members.idx_members_name_email (name, email) [unique]
-- create_primary_key_constraint: members.members_id_primary_key (id)
-- create_primary_key_constraint: posts.pk_posts_id_author_id (id, author_id)
-- create_unique_constraint: members.members_email_unique (email)
-- create_unique_constraint: members.members_name_unique (name)
-- create_unique_constraint: posts.unique_author_slug (author_id, slug)
-- create_foreign_key_constraint: posts.posts_author_fk (author_id) -> members (id) ON DELETE CASCADE
β Migration file generated: migrations/1755525514175.jsongenerate command will fail if there is a pending migration. Use --ignore-pending option in that case.
PROTIPS:
- Kyrage has built-in mechanism to spin up ephemeral database for development as Docker container that can be used as
kyrage generate --devcommand. See Dev Database for more detail. - If you iterate multiple schema changes during feature development and want to organize them before applying, kyrage can squash migrations by
kyrage generate --squash. See Squash Migrations for more detail.
You can use apply --plan beforehand to check SQL queries that will be executed in the next time:
$ kyrage apply --plan --pretty
create table "members" (
"id" uuid not null,
"email" text not null,
"name" text,
"age" integer,
"createdAt" timestamptz default now ()
)
create table "posts" (
"id" uuid,
"author_id" uuid,
"slug" text not null,
"title" text,
"content" text not null
)
create unique index "idx_members_name_email" on "members" ("name", "email")
alter table "members" add constraint "members_id_primary_key" primary key ("id")
alter table "posts" add constraint "pk_posts_id_author_id" primary key ("id", "author_id")
alter table "members" add constraint "members_email_unique" unique ("email")
alter table "members" add constraint "members_name_unique" unique ("name")
alter table "posts" add constraint "unique_author_slug" unique ("author_id", "slug")
alter table "posts" add constraint "posts_author_fk" foreign key ("author_id") references "members" ("id") on delete cascadeIf everything looks good, execute the generated migrations:
$ kyrage apply
β Migration applied: 1755525514175kyrage supports generating migrations against ephemeral development databases using Docker containers that is pretty much similar to the concept of Atlas's Dev Database.
The dev database will:
- Start a fresh container with your specified database image (or reuse existing one)
- Apply all existing migrations to establish the current baseline
- Compare your schema against this clean state
- Generate the migration file and apply it immediately (no need to run
kyrage applymanually in in dev mode) - Optionally maintain the container for subsequent operations (if the container is booted with
kyrage dev start)
# Generate migration against a clean dev database
$ kyrage generate --dev
π Starting dev database for migration generation...
β Dev database started: postgres
-- create_table: users
-> column: id ({"type":"uuid","primaryKey":true,"notNull":true})
-> column: email ({"type":"text","notNull":true,"unique":true})
β Migration file generated: migrations/1755525514175.json
β Migration applied: 1755525514175
β Dev database stoppedThis is useful when you want to generate migrations without affecting your production database state.
kyrage automatically detects and reuses development database containers based on the Dev Database container state:
// kyrage.config.ts
export default defineConfig({
dev: {
container: {
image: "postgres:17",
name: "kyrage-dev-db" // Optional custom container name
}
},
// ... other config
});# Without dev start container running
$ kyrage generate --dev
π Starting temporary dev database...
π Applying 2 pending migrations...
β Applied 2 migrations
β Migration file generated: migrations/1755525514175.json
β Migration applied: 1755525514175
β Temporary dev database stopped
# After starting dev database
$ kyrage dev start
π Starting dev database...
β Applied 2 migrations
β¨ Dev database ready: postgresql://postgres:password@localhost:32768/test
# Subsequent generate --dev automatically reuses dev start container
$ kyrage generate --dev
π Reusing existing dev start container...
π Applying 1 pending migration to dev start container...
β Applied 1 migration
β Migration file generated: migrations/1755525514176.json
β Migration applied: 1755525514176
β¨ Dev start container remains runningThis feature is expected to be used in steamlined development cycle to make multiple schema changes with the populated data on your database kept, which will help debug or test your latest schema with the developing app that uses the database.
kyrage also provides handy commands to manage your development database containers without Docker CLI:
# Start persistent dev database with migrations applied
$ kyrage dev start
π Starting dev database...
β Applied 2 migrations
β¨ Dev database ready: postgresql://postgres:password@localhost:32768/test
# Check status of dev containers
$ kyrage dev status
Running: abc123def456 (postgres:17)
# Get connection URL for running dev container
$ kyrage dev get-url
postgresql://postgres:password@localhost:32768/test
# Connect directly to dev database using psql
$ psql $(kyrage dev get-url)
psql (17.0)
Type "help" for help.
test=# \dt
List of relations
Schema | Name | Type | Owner
--------+---------+-------+----------
public | members | table | postgres
public | posts | table | postgres
(2 rows)
# Clean up all kyrage dev containers
$ kyrage dev clean
β Cleaned up dev containersDuring feature development, you may generate multiple pending migration files. Use the --squash option to consolidate them into a single migration:
# Before squashing
$ ls migrations/
1755525514175.json # β
applied
1755525514180.json # β³ pending
1755525514185.json # β³ pending
1755525514190.json # β³ pending
$ kyrage generate --squash
Found 3 pending migrations to squash:
- 1755525514180.json
- 1755525514185.json
- 1755525514190.json
ποΈ Removed 3 pending migration files
-- create_table: users
-> column: id ({"type": "uuid", "primaryKey": true, "notNull": true})
-> column: email ({"type": "text", "notNull": true, "unique": true})
-> column: name ({"type": "text", "notNull": false})
βοΈ Generated squashed migration: migrations/1755525514200.json
# After squashing
$ ls migrations/
1755525514175.json # β
applied (preserved)
1755525514200.json # β³ pending (squashed)This consolidates multiple pending migrations into a single migration representing the final desired state. Applied migrations are never touched.
A common pattern is to use dev databases for iterative development, then squash migrations before deploying to staging/production:
# Development phase - multiple iterations with dev database
$ kyrage generate --dev # 1st iteration - adds users table
$ kyrage generate --dev # 2nd iteration - adds posts table
$ kyrage generate --dev # 3rd iteration - adds indexes
# Development complete - consolidate pending migrations
$ kyrage generate --squash # Squash all pending migrations into one
# Deploy to staging/production
$ kyrage apply # Apply the single squashed migrationIn this workflow:
- Dev database acts like a feature branch - rapid iteration with immediate application
- Squashing consolidates the development history into a clean, single migration
- Production deployment applies one cohesive migration instead of multiple incremental changes
This approach keeps your production migration history clean while allowing flexible development iterations.
| Command | Description |
|---|---|
kyrage generate |
Compare schema with database and generate migration file |
kyrage generate --squash |
Consolidate pending migrations into a single migration file |
kyrage generate --dev |
Generate migration using development database (auto-detects container reuse) |
kyrage apply |
Apply all pending migrations to the database |
kyrage dev start |
Start persistent development database with migrations applied |
kyrage dev status |
Show status of running development database containers |
kyrage dev get-url |
Print connection URL for running development database (use with psql $(kyrage dev get-url)) |
kyrage dev clean |
Remove all kyrage-managed development database containers |
Your kyrage.config.ts file supports the following options:
import { defineConfig } from "@izumisy/kyrage";
export default defineConfig({
database: {
dialect: "postgres" | "cockroachdb", // Database dialect
connectionString: string, // Database connection string
},
// Optional: Development database configuration
dev: {
// Option 1: Use Docker container (requires Docker)
container: {
image: "postgres:17",
name: "kyrage-dev-db" // Optional custom container name
},
// Option 2: Use existing database connection
connectionString: "postgres://..."
},
tables: [
// table definitions
],
});Kyrage internally employes unjs/c12 that helps users define environment specific configurations that can be switched with NODE_ENV.
import { defineConfig } from "@izumisy/kyrage";
export default defineConfig({
// Using different databases by environment
$development: {
database: {
dialect: "postgres",
connectionString: "psql://dev:pass@localhost/myapp_dev"
},
// Use containerized dev database for clean migration generation
dev: {
container: {
image: "postgres:17",
}
}
},
$production: {
database: {
dialect: "cockroachdb",
connectionString: "psql://user:[email protected]/myapp_prod?ssl=true"
},
// Use existing staging database for dev migrations
dev: {
connectionString: "psql://staging:[email protected]/myapp_staging"
}
},
// Tables are common in all environment
tables: [
/* ... */
]
});Use the defineTable function to define your database tables:
import { column, defineTable } from "@izumisy/kyrage";
const tableName = defineTable(
"table_name", // Table name
{ columnName: column("type", options) }, // Column definitions (record)
(t) => [ // Optional: table constraints
t.primaryKey(["col1", "col2"]),
t.index(["col1", "col2"], { unique: true }),
t.unique(["col1", "col2"], { name: "custom_name" }),
t.reference("col1", anotherTable, "id")
]
);The column function accepts the following options:
column("dataType", {
primaryKey?: boolean, // Creates a PRIMARY KEY constraint
unique?: boolean, // Creates a UNIQUE constraint
notNull?: boolean, // NOT NULL constraint
defaultSql?: string, // Default SQL expression (e.g., "now()", "gen_random_uuid()")
})// Create a regular index
t.index(["column1", "column2"])
// Create a unique index
t.index(["column1", "column2"], { unique: true })
// Create an index with custom name
t.index(["column1"], { name: "custom_idx_name" })// Single column primary key
column("uuid", { primaryKey: true })
// Composite primary key
t.primaryKey(["id", "tenant_id"])
// Custom constraint name
t.primaryKey(["id", "tenant_id"], { name: "pk_custom_name" })// Single column unique
column("email", { unique: true })
// Composite unique constraint
t.unique(["tenant_id", "slug"])
// Custom constraint name
t.unique(["tenant_id", "slug"], { name: "unique_tenant_slug" })// Single column foreign key
t.reference("user_id", usersTable, "id")
// Multiple column foreign key
t.reference(["tenant_id", "user_id"], usersTable, ["tenant_id", "id"])
// With referential actions and custom name
t.reference("author_id", usersTable, "id", {
onDelete: "cascade",
onUpdate: "restrict",
name: "posts_author_fk"
})kyrage automatically generates constraint names following these patterns:
- Primary Key:
{table}_{column}_primary_key(single) orpk_{table}_{columns}(composite) - Unique Constraint:
{table}_{column}_unique(single) oruq_{table}_{columns}(composite) - Index:
idx_{table}_{columns}
You can override these by providing a custom name option.
Check out the examples/basic directory for a complete working example with:
- Configuration setup
- Schema definitions
- Generated migrations
- Applied database changes
Contributions are welcome! Please feel free to submit issues and pull requests.
MIT License - see LICENSE file for details.