Skip to content

Migrations — the full surface

The Migrations intro covers the smallest migration that works: Migration.create(Model, { columns }) + npx cascade migrate. This page picks up where that ends — every column type, every modifier, compound indexes, schema evolution with Migration.alter, foreign keys, specialized indexes, and the programmatic runner.

The guiding principle: Migration.create and Migration.alter are declarative and cover almost everything. You pass an object describing the end state; Cascade emits the DDL and infers the rollback. The extends Migration class form is a narrow escape hatch for genuinely imperative migrations — reach for it last, not first.

Beyond the columns you declare, Migration.create wires a few system columns automatically so you never declare them by hand:

  • Primary key — resolved from the primaryKey option → the data source’s migrationDefaults.primaryKey → "int". Pass { primaryKey: "uuid" | "bigInt" | false } to override.
  • Timestamps — createdAt / updatedAt, on by default. Disable with { timestamps: false }.
  • Soft-delete column — deletedAt, added only when the model’s delete strategy resolves to "soft".

The strategy is resolved exactly as destroy() resolves it: the model’s static deleteStrategy → the data source’s defaultDeleteStrategy → "permanent". Because soft delete is usually an app-wide policy set once on the data source, every Migration.create then gets the deletedAt column with no extra config — and the schema stays in step with what destroy() writes at runtime.

// User (or the data source) sets deleteStrategy "soft" → deletedAt is added.
export default Migration.create(User, {
name: text().notNullable(),
});

Details:

  • The column name comes from the model’s deletedAtColumn (default "deletedAt"), so it matches the runtime exactly.
  • It fires for "soft" only — "permanent" and "trash" add nothing. No driver defaults to "soft", so it never appears unless soft delete is opted into.
  • Tri-state opt-out via the third argument: { softDeletes: false } never adds it; { softDeletes: true } always adds it; omitted follows the resolved strategy.
  • A model with deletedAtColumn = false is never wired, and a deletedAt you declare yourself in the column map is not duplicated.

See delete strategies for the strategies themselves and how to query soft-deleted rows.

Every column starts with a helper imported from @warlock.js/cascade. Each returns a builder you chain modifiers onto.

Strings

import { string, char, text, mediumText, longText } from "@warlock.js/cascade";
string(50); // VARCHAR(50), default length 255
char(3); // fixed-length CHAR(3)
text(); // unlimited TEXT
mediumText(); // MEDIUMTEXT
longText(); // LONGTEXT

Numbers

import {
integer,
int,
bigInteger,
bigInt,
smallInteger,
tinyInteger,
float,
double,
decimal,
} from "@warlock.js/cascade";
integer(); // INTEGER (alias: int)
bigInteger(); // BIGINT (alias: bigInt)
smallInteger(); // SMALLINT (alias: smallInt)
tinyInteger(); // TINYINT (alias: tinyInt)
float(); // FLOAT
double(); // DOUBLE
decimal(10, 2); // DECIMAL(10,2) — precision, scale (default 8, 2)

Booleans, dates, times

import { bool, boolCol, date, dateTime, timestamp, time, year } from "@warlock.js/cascade";
boolCol(); // BOOLEAN (alias: bool — named to avoid the JS `boolean` clash)
date(); // DATE
dateTime(); // DATETIME
timestamp(); // TIMESTAMP
time(); // TIME
year(); // YEAR

JSON, binary, identifiers

import { json, objectCol, binary, blobCol, uuid, ulid } from "@warlock.js/cascade";
json(); // JSON / JSONB (alias: objectCol)
binary(); // BINARY / BLOB (alias: blobCol)
uuid(); // UUID
ulid(); // ULID

Network, spatial, AI

import {
ipAddress,
macAddress,
point,
polygon,
lineString,
geometry,
vector,
} from "@warlock.js/cascade";
ipAddress(); // IP address column
macAddress(); // MAC address column
point(); // geo point
polygon(); // polygon
geometry(); // generic geometry
vector(1536); // fixed-dimension vector for AI embeddings

Enums, sets, Postgres arrays

import { enumCol, setCol, arrayText, arrayInt, arrayUuid, arrayJson } from "@warlock.js/cascade";
enumCol(["active", "inactive", "pending"]); // ENUM
setCol(["read", "write", "admin"]); // SET (multi-value)
arrayText(); // TEXT[] (Postgres)
arrayInt(); // INTEGER[]
arrayUuid(); // UUID[]
arrayJson(); // JSONB[]

The array helpers (arrayInt, arrayBigInt, arrayFloat, arrayDecimal, arrayBoolean, arrayText, arrayDate, arrayTimestamp, arrayUuid, arrayJson) are Postgres-native array columns. On MongoDB, an embedded array is just a json() field.

Chain modifiers to shape each column:

import { Migration, string, integer, decimal, text, timestamp } from "@warlock.js/cascade";
import { Product } from "../product.model";
export default Migration.create(Product, {
sku: string(40).notNullable().unique(),
name: string().notNullable().index(),
description: text().nullable(),
stock: integer().unsigned().default(0),
price: decimal(10, 2).notNullable(),
released_at: timestamp().nullable().comment("null until the product ships"),
});

Grouped by what they do:

Nullability and defaults

  • .nullable() — column accepts NULL.
  • .notNullable() — column rejects NULL.
  • .default(value) — default applied when the column isn’t set on insert (string, number, or boolean).

Single-column keys and constraints

  • .primary() — primary key. (Rarely needed — id is auto-managed.)
  • .unique() — unique index on this one column.
  • .index() — non-unique index on this one column.
  • .autoIncrement() — auto-incrementing integer.
  • .unsigned() — unsigned numeric (SQL).

Metadata and placement

  • .comment(text) — column comment (SQL).
  • .after(columnName) — order this column after another (SQL).

Foreign keys

  • .references(tableOrModel) — declares an FK. Accepts a model class (Cascade reads .table) or a table-name string.
  • .onDelete(action) / .onUpdate(action) — "cascade", "restrict", "setNull", "noAction".

Vector index

  • .vectorIndex(options) — similarity index inline on a vector() column. Options: similarity ("cosine" | "euclidean" | "dotProduct"), name, lists.

.index() and .unique() on a column builder are single-column only. Multi-column (compound) indexes are declared in the migration options — see next.

Multi-column indexes don’t go on a column — they go in the index / unique arrays in Migration.create’s options (third argument):

import { Migration, uuid, text, timestamp } from "@warlock.js/cascade";
import { Summary } from "../summary.model";
export default Migration.create(
Summary,
{
organization_id: uuid().notNullable(),
content_type: text().notNullable(),
content_id: uuid().notNullable(),
content_language: text().notNullable(),
body: text().notNullable(),
generated_at: timestamp().notNullable(),
},
{
index: [
{ columns: ["organization_id", "content_type", "content_id"] },
{ columns: ["organization_id", "generated_at"], name: "idx_org_recent" },
],
unique: [
{
columns: ["organization_id", "content_id", "content_language"],
name: "uq_summary_idempotency",
},
],
},
);

Each index / unique entry is { columns, name?, using?, include?, concurrently? }:

  • columns — a single column or an array. Arrays make it compound; column order matters (leftmost-prefix rule applies the same as raw SQL).
  • name — explicit index/constraint name. Auto-generated when omitted. Name it when a later Migration.alter needs to reference it for a drop.
  • using — Postgres access method: "btree" (default), "hash", "gin", "gist", "brin", "ivfflat", "hnsw". This is where JSON/array GIN indexes and pgvector tuning live: { columns: ["metadata"], using: "gin" }.
  • include — covering-index extra columns (Postgres INCLUDE).
  • concurrently — build without locking the table (Postgres). Essential for adding an index to a large live table.

The rule of thumb: single column → .index() / .unique() on the column. Two or more columns → the index / unique options array.

The common shape — a child column pointing at a parent:

import { Migration, uuid, text } from "@warlock.js/cascade";
import { User } from "app/users/models/user/user.model";
import { Post } from "../post.model";
export default Migration.create(Post, {
author_id: uuid().references(User).onDelete("cascade").notNullable(),
title: text().notNullable(),
body: text().notNullable(),
});

.references(User) takes the model class directly — Cascade reads User.table. Pass the string "users" instead when the class would cause an import cycle.

Referential actions:

  • "cascade" — delete/update the child rows when the parent goes.
  • "setNull" — null out the FK column (the column must be .nullable()).
  • "restrict" — block the parent delete/update while children exist.
  • "noAction" — defer the check; database-dependent.

New tables use Migration.create. Changing an existing table uses Migration.alter — and it’s fully declarative. You don’t write an imperative up(); you describe the change as a schema object. Each change is its own migration file with its own timestamp; you never edit a migration that’s already run in production.

import { Migration, text, string, boolCol } from "@warlock.js/cascade";
import { Team } from "app/teams/models/team/team.model";
import { User } from "../user.model";
export default Migration.alter(User, {
add: {
phone: text().nullable(),
email_verified: boolCol().default(false),
},
drop: ["legacy_token"],
rename: { fname: "first_name" },
modify: { email: string(320).notNullable() },
addIndex: [{ columns: ["first_name", "last_name"], name: "idx_full_name" }],
addUnique: [{ columns: ["email"] }],
addForeign: [{ column: "team_id", references: Team, onDelete: "cascade" }],
dropIndex: ["idx_old_lookup"],
dropUnique: [["phone"]],
dropForeign: [{ columnOrConstraint: "old_owner_id", referencesTable: "users" }],
});

Every key is optional — include only what the migration changes. The AlterSchema surface, grouped by intent:

Columns

  • add — ColumnMap; new columns. Keys become column names.
  • drop — string array of column names to drop.
  • rename — { oldName: newName }.
  • modify — ColumnMap; redefine existing columns.

Indexes and constraints

  • addIndex — [{ columns, name?, options?: { include?, concurrently? } }].
  • dropIndex — [name | columnsArray].
  • addUnique / dropUnique — same shape as index; drop by columns array.
  • addExpressionIndex — [{ expressions, name?, options? }] (Postgres functional indexes like lower(email)).
  • addForeign — [{ column, references, on?, onDelete?, onUpdate? }]; references takes a model class or table string.
  • dropForeign — [{ columnOrConstraint, referencesTable? }].
  • addCheck / dropCheck — [{ name, expression }] / [name].

Specialized indexes

  • addFullText / dropFullText — full-text search indexes.
  • addGeoIndex / dropGeoIndex — geo-spatial.
  • addVectorIndex / dropVectorIndex — [{ column, options: { dimensions, similarity } }].
  • addTTLIndex / dropTTLIndex — [{ column, expireAfterSeconds }] (MongoDB-primary; auto-expire documents).

Raw

  • raw — string or string array of SQL to run as part of the alter.

Migration.create auto-infers its rollback (drop the table). Migration.alter infers one only when it is add-only — just add, with no custom up or down. Any other alter can’t always know the inverse (a modify, say), so provide a down in the options when the rollback matters.

// Add-only: no down() needed.
export default Migration.alter(User, {
add: { phone: text().nullable() },
});

The derived down() drops the added columns on SQL. On a document store (MongoDB) it only removes the _migrations ledger row, because up() changed no documents.

The two kinds of store are not symmetric:

SQLMongoDB
add in up()Adds the columnNo-op: nothing is written to any document
dropColumn in down()Drops the column added by up()Runs $unset on every document, erasing all values stored since
Add-only alter, derived down()Drops the added columnsLedger-only
Plain dropColumn() / dropColumns() in down()AppliedSkipped, with a warning
dropColumn(name, { destructive: true }) in down()AppliedApplied: unsets the field on every document

So the conventional down() that mirrors an add with dropColumn is safe on SQL and destructive on MongoDB. Cascade skips that drop on MongoDB unless you opt in:

export default Migration.alter(
User,
{ add: { legacy_flag: bool().nullable() } },
{
down() {
// The flag is disposable, so erasing it from every document is intended.
this.dropColumn("legacy_flag", { destructive: true });
},
},
);

Forward drops are unaffected: drop: [...] and dropColumn() in up() still run on every store. dropTable() and truncateTable() in down() still run too, which is why migrate:rollback shows them in its summary (see Rolling back safely). In a hand-written down() that must also run on SQL, guard the drop with this.isDocumentStore:

down() {
if (!this.isDocumentStore) this.dropColumn("full_name");
}

migrate:rollback, migrate --fresh and warlock migrate --rollback print a destructive-operation summary before they run: per migration, the collections or tables dropped, the fields or columns unset, and how many documents or rows are affected (counted for MongoDB, and for table drops on both stores).

Rollback plan:
2026-10-01_add-phone
- unset field(s) legacy_flag on collection "users" [1204 document(s)]
1 migration(s) only update the migrations ledger (no data removed).
  • In development (NODE_ENV=development; warlock migrate also treats an unset NODE_ENV as development, the standalone cascade binary does not) the rollback runs right after the summary.
  • Everywhere else it asks for confirmation. Pass --yes (or --force) to confirm in a script. With no terminal and no flag it refuses and exits with an error.
  • A rollback that removes no data (ledger-only, or only skipped drops) runs without a question.

The counts come from countDocuments on the affected collection, so a very large collection pays one count per listed operation. The plan calls each migration’s down() on a throwaway instance to see what it queues; a down() that talks to the database directly, rather than through the migration methods, runs during planning.

Both Migration.create and Migration.alter accept up, raw, and down in their options. This is how you attach imperative logic (data backfills, trigger creation, custom SQL) without dropping to the class form:

import { Migration, text } from "@warlock.js/cascade";
import { User } from "../user.model";
export default Migration.alter(
User,
{
add: { full_name: text().nullable() },
},
{
raw: "UPDATE users SET full_name = first_name || ' ' || last_name",
down() {
// Skipped on MongoDB, where it would unset full_name on every document.
this.dropColumn("full_name");
},
},
);

raw runs before the up hook. up runs after the declarative definitions are applied. This covers the overwhelming majority of “I need a bit of custom logic” cases — reach for the class form only when the migration is primarily imperative.

The class form — the narrow escape hatch

Section titled “The class form — the narrow escape hatch”

When a migration is genuinely imperative — multi-step orchestration, conditional DDL, logic the declarative schema and option hooks can’t express — extend Migration:

import { Migration } from "@warlock.js/cascade";
export default class extends Migration {
public async up() {
if (await this.hasIndex("idx_legacy")) {
this.dropIndex("idx_legacy");
}
this.index(["status", "created_at"], "idx_status_recent", { concurrently: true });
}
public async down() {
this.dropIndex("idx_status_recent");
}
}

Use this only when the declarative form genuinely can’t express the migration — a runtime hasIndex check, branching on existing schema, a sequence that has to interleave DDL and data in a specific order. If you find yourself writing a class-form migration that’s just add column / drop column, it should have been Migration.alter.

There’s also a lighter migrate(Model, { up, down, name }) helper if you want the imperative hooks without a class declaration — same capability, less ceremony.

For DDL the structured API can’t express at all (a stored procedure, a database-specific statement), the raw-statement form takes plain SQL with no model:

import { Migration } from "@warlock.js/cascade";
export default Migration.create({
name: "add_price_check_constraint",
up: "ALTER TABLE products ADD CONSTRAINT price_positive CHECK (price >= 0)",
down: "ALTER TABLE products DROP CONSTRAINT price_positive",
});

up / down accept a single SQL string or an array of statements. You own the SQL and its rollback. Reserve this for what the structured methods genuinely can’t reach — the structured form survives driver differences; raw does not.

(For a CHECK constraint specifically, Migration.alter’s addCheck is the structured path — prefer it. The raw form is the example here only because it’s the clearest illustration of the escape hatch.)

Postgres wraps each migration in a transaction by default (DDL is transactional in PG), so a mid-migration failure rolls back cleanly. MongoDB can’t run DDL in a transaction — its migrations are non-transactional by nature.

Override per migration when an operation can’t be transactional (Postgres CREATE INDEX CONCURRENTLY cannot run inside a transaction):

export default Migration.create(
Product,
{/* columns */},
{
transactional: false,
index: [{ columns: ["sku"], concurrently: true }],
},
);

The data-source-wide default lives in the migrations.transactional connection option (see the Configuration guide).

Changed in 5.21: migrations run in authored order — there is no global phase sort any more. transactional: false and a migration’s own dataSource are honoured. The description below is the pre-5.21 behaviour.

Cascade pools the SQL from every pending migration and sorts it globally, by three keys in order:

  1. Phase — the dependency-safe DDL order, derived from each statement: extensions and types first, then CREATE TABLE, then ADD COLUMN, then foreign keys, then drops and alters. A CREATE TABLE from a newer migration still runs before an older migration’s ALTER.
  2. createdAt — extracted from the filename’s timestamp prefix (MM-DD-YYYY_HH-MM-SS-name.migration.ts; files from generate.model use <timestamp>-name.migration.ts).
  3. Migration name — alphabetical, and only when the timestamps can’t decide.

Because phase wins, ordering between migrations matters most for statements that share a phase — two migrations altering the same table, for example.

Two migrations with the same timestamp fall through to the name, so keep timestamps unique when the order between them matters. Override explicitly when generating migrations outside the filename convention:

export default Migration.create(User, {/* ... */}, { createdAt: "2026-05-16T10:00:00Z" });

npx cascade migrate is a thin wrapper over Cascade’s Operations API — a small set of named functions over the migration runner. For CI scripts, test setup, container init, or any embedded use case, call the API directly:

import { connectToDatabase, migrationRunner, runMigrations } from "@warlock.js/cascade";
import databaseConfig from "./config/database";
import UserMigration from "./src/app/users/models/user/migrations/05-11-2026_10-00-00-user.migration";
await connectToDatabase(databaseConfig);
UserMigration.migrationName = "user";
migrationRunner.register(UserMigration);
await runMigrations();

Every CLI command maps to one Operations API function:

CLIOperations APIWhat it does
npx cascade migraterunMigrations()Run every pending migration
npx cascade migrate:rollbackrollbackMigrations()Roll back the last batch
npx cascade migrate:rollback --batches 3rollbackMigrations({ batches: 3 })Roll back the last n batches
npx cascade migrate:rollback --allrollbackMigrations({ all: true })Roll back everything
npx cascade migrate:rollback --yesconfirmRollback({ yes: true })Confirm a data-removing rollback without a prompt
npx cascade migrate -ffreshMigrate()Roll back everything, then re-run
npx cascade migrate:listlistExecutedMigrations()Read the _migrations tracking table
npx cascade migrate --sqlexportMigrationsSQL()Write .up.sql / .down.sql files; no DB writes

Both the standalone cascade binary and warlock-core’s CLI consume the same Operations API — one code path, two surfaces. Full signatures, options, and return types: Operations API reference. Standalone-CLI installation and .env configuration: CLI guide.

A new notNullable column with no default fails against a populated table. Add nullable, backfill via raw, then tighten — all in one Migration.alter, no class form:

export default Migration.alter(
User,
{
add: { full_name: text().nullable() },
},
{
raw: [
"UPDATE users SET full_name = first_name || ' ' || last_name",
"ALTER TABLE users ALTER COLUMN full_name SET NOT NULL",
],
down() {
this.dropColumn("full_name");
},
},
);

Compound index on a large live table (Postgres)

Section titled “Compound index on a large live table (Postgres)”
export default Migration.create(
Order,
{/* columns */},
{
transactional: false, // CONCURRENTLY can't run in a transaction
index: [
{
columns: ["customer_id", "status"],
name: "idx_orders_customer_status",
concurrently: true,
},
],
},
);
export default Migration.create(
Event,
{
metadata: json().notNullable(),
},
{
index: [{ columns: ["metadata"], using: "gin" }],
},
);

For large initial seeds, declare the vector() column without an inline .vectorIndex(...), bulk-load, then add the index in a follow-up Migration.alter with addVectorIndex — the build cost is amortised instead of paid per insert. See the Vector search guide.