@sqlml/cli
v0.5.2
Published
DBML-first database toolkit — generate TypeORM/Prisma models, manage migrations, version schemas, and browse data via Web UI.
Maintainers
Readme
Design your database schema in DBML, generate TypeORM entities or Prisma schemas, manage migrations, track schema versions, and browse your data with a built-in Web UI — all from one CLI.
Install
npm install -g @sqlml/cliQuick Start
# 1. Initialize project (picks TypeORM or Prisma)
sqlml init
# 2. Edit your DBML schema
# → sqlml/src/lib/schema/my-schema.dbml
# 3. Generate ORM models from DBML
sqlml generate my-schema
# 4. Set up migrations
sqlml migration:init
sqlml migration:generate add-users
sqlml migration:up
# 5. Launch the Web UI
sqlml uiWhat It Does
| Capability | Description |
|------------|-------------|
| Schema Design | Write database schemas in DBML — a clean, readable DSL |
| Model Generation | Auto-generate TypeORM entity classes or Prisma schema files |
| Migrations | Generate, run, revert, and track migrations (both ORMs) |
| Schema Versioning | Snapshots, diffs, and change history for your DBML |
| Advanced Postgres | CHECK constraints, FK referential actions, partial/expression/covering indexes, index access methods (USING GIST/GIN), extension types (CREATE EXTENSION for PostGIS geography, citext, …), array columns (text[]/uuid[]), and table partitioning — declared in DBML, round-tripped to SQL, migrations, and entities |
| Safe TypeORM output | Relation<T> wrappers (imported as type Relation) compile under strict isolatedModules + emitDecoratorMetadata and break the load-time circular-dependency TDZ between mutually-referential entities; CHECK predicates surface as @Check decorators |
| CI-friendly | --yes / --non-interactive (and CI=true) for non-prompting generation; OFFLINE_GENERATION=true builds entities (relations, many-to-many, indexes, enums, arrays) straight from DBML with no database |
| Web UI | Visual dashboard, schema editor, data browser, SQL query editor |
| Multi-Database | PostgreSQL, MySQL, MariaDB, SQLite, MSSQL, Oracle |
DBML Example
Validate your DBML schema online at dbdiagram.io
Table users {
id uuid [primary key, default: `uuid_generate_v4()`]
username varchar [not null, unique]
email varchar [not null, unique]
role user_role [not null, default: 'user']
created_at timestamptz [default: `now()`]
}
Table posts {
id uuid [primary key, default: `uuid_generate_v4()`]
title varchar(255) [not null]
body text
author_id uuid [not null, ref: > users.id]
published_at timestamptz
}
Enum user_role {
admin
user
moderator
}Commands
Core
sqlml init # Initialize project (interactive)
sqlml generate [schema] # Generate SQL + entities from DBML
sqlml direct # Generate entities from existing database
sqlml ui # Start Web UI at localhost:4200
sqlml ui-stop # Stop Web UIMigrations
sqlml migration:init # Baseline existing database
sqlml migration:generate <name> # Generate migration from schema diff
sqlml migration:up # Run pending migrations
sqlml migration:revert # Revert last migration
sqlml migration:status # Show migration status
sqlml migration:from-dbml <name> --schema <name> # Generate migration from DBML changes
sqlml migration:from-dbml <name> --online # Non-blocking emission: CHECK NOT VALID + VALIDATE, CREATE INDEX CONCURRENTLY (Postgres)
sqlml migration:from-dbml <name> --idempotent # Tolerate a pre-existing CHECK / FK constraint (adoption)
sqlml migration:from-dbml <name> --yes # Non-interactive: create files without prompting (also via CI=true)--schema accepts a bare schema name (resolved against SCHEMA_DIR), a .dbml filename, or an absolute path — see Multi-schema workspaces. --online / --idempotent apply to CHECK, FK, and index emission — see CHECK constraints, Foreign key referential actions, and Partial, expression & covering indexes. --yes / --non-interactive are available on migration:from-dbml, migration:up, migration:revert, and migration:force — see Non-interactive mode.
Schema Versioning
sqlml diff # Show schema changes since last snapshot
sqlml snapshot:create # Create schema snapshot
sqlml snapshot:list # List snapshots
sqlml snapshot:show <version> # Show specific snapshot
sqlml schema:history # Full change historyORM Support
TypeORM
sqlml init # → Select "typeorm"
sqlml generate # → Generates decorated entity classesGenerates entity files with @Entity, @Column, @PrimaryColumn, relationship decorators, indexes, and configurable naming conventions.
Prisma
sqlml init # → Select "prisma"
sqlml generate # → Generates schema.prisma- Generates
schema.prismafrom DBML migration:initbaselines withprisma migrate diff+ auto-addsuuid-osspextensionmigration:uprunsprisma migrate deploymigration:generateusesprisma migrate dev --create-only
Web UI
sqlml uiOpens at http://localhost:4200 with:
- Dashboard — Project overview, ORM-specific command reference
- Schema Editor — Monaco Editor with DBML syntax highlighting
- Schema Diff — Side-by-side snapshot comparison with color-coded changes
- Migrations — View, run, and revert migrations
- Database — Connection manager, schema tree browser, paginated data grid with inline editing, SQL query editor (Cmd+Enter to run)
- Settings — Configuration editor
See @sqlml/ui for standalone installation.
Database to Entities (Direct Mode)
Generate entities from an existing database without DBML:
# PostgreSQL
sqlml -h localhost -d mydb -u postgres -x pass -e postgres -o ./src/entities
# MySQL
sqlml -h localhost -d mydb -u root -x pass -e mysql -p 3306 -o ./src/entities
# SQLite
sqlml -d ./database.sqlite -e sqlite -o ./src/entities
# MSSQL
sqlml -h localhost -d mydb -u sa -x pass -e mssql -o ./src/entitiesConfiguration
Settings are stored in .config-sqlml (auto-created by sqlml init).
Add
.config-sqlmland.sqlml/to.gitignoreto protect credentials.
Connection
| Variable | Default | Description |
|----------|---------|-------------|
| ORM_TYPE | typeorm | ORM to use: typeorm or prisma |
| DB_TYPE | postgres | Database engine: postgres, mysql, mariadb, sqlite, mssql, oracle |
| DB_HOST | localhost | Database server hostname |
| DB_PORT | 5432 | Database server port |
| DB_NAME | — | Database name (or file path for SQLite) |
| DB_USER | — | Database username |
| DB_PASSWORD | — | Database password |
| DB_SCHEMA | public | Schema(s) to use — comma-separated for multiple (e.g. auth,store,cms) |
| DB_SSL | false | Enable SSL connection |
File Paths
| Variable | Default | Description |
|----------|---------|-------------|
| SCHEMA_DIR | sqlml/src/lib/schema | Directory for .dbml schema files |
| SQL_DIR | sqlml/src/lib/sql | Directory for generated SQL files |
| ENTITIES_DIR | sqlml/src/lib/entities | Directory for generated entity/model files |
| MIGRATIONS_DIR | sqlml/src/lib/migrations | Directory for migration files |
| NAMESPACE_BY_SCHEMA | false | When true, splits entities/, migrations/, sql/, and .sqlml/snapshots/ into <schema>/ subdirectories — see Multi-schema workspaces |
Table Filtering
| Variable | Default | Description |
|----------|---------|-------------|
| SKIP_TABLES | — | Comma-separated list of tables to exclude from generation |
| ONLY_TABLES | — | Comma-separated list of tables to include (excludes all others) |
Naming Conventions
| Variable | Default | Values | Description |
|----------|---------|--------|-------------|
| CASE_FILE | param | param, pascal, camel, snake, none | File naming convention (e.g. user-profile.ts) |
| CASE_ENTITY | pascal | pascal, camel, snake, none | Entity class naming (e.g. UserProfile) |
| CASE_PROPERTY | camel | pascal, camel, snake, none | Property naming (e.g. firstName) |
| SUFFIX_FILE | — | any string | Suffix appended to file names (e.g. .entity) |
| SUFFIX_CLASS | — | any string | Suffix appended to class names (e.g. Entity) |
Generation Options
| Variable | Default | Description |
|----------|---------|-------------|
| PROPERTY_VISIBILITY | none | Property visibility modifier: none, public, protected, private |
| STRICT_MODE | none | TypeScript strict mode: none or ! (definite assignment) |
| LAZY | false | Generate lazy-loaded relations |
| ACTIVE_RECORD | false | Use Active Record pattern (extends BaseEntity) |
| RELATION_IDS | true | Generate @RelationId decorated properties |
| SKIP_SCHEMA | false | Skip schema option in @Entity decorator |
| GENERATE_CONSTRUCTOR | true | Generate constructor in entity classes |
| PLURALIZE_NAMES | true | Pluralize relation property names |
| INDEX_FILE | true | Generate index.ts barrel file for entities |
| INDEX_FILE_MODE | overwrite | Barrel rebuild strategy. overwrite rewrites the barrel from the current generation only. cumulative scans the entities directory and re-exports every existing *.entity.ts file — see Cumulative barrel |
| EXPORT_TYPE | named | Export style: named or default |
| EOL | LF | Line ending style: LF or CRLF |
Eager relation properties are typed with TypeORM's Relation<T> / Relation<T[]> wrapper (e.g. product!: Relation<Products>). This is the standard remedy for the load-time circular-import crash (Cannot access 'X' before initialization) between two mutually-referential entities under emitDecoratorMetadata: the wrapper erases to Object for the property's design:type, breaking the cycle while keeping eager semantics and the () => Entity thunk. LAZY relations keep their Promise<T> typing (already cycle-safe).
Relation is imported with an inline type modifier — import { ManyToOne, …, type Relation } from "typeorm" — so the generated entities compile cleanly under a standard strict library config (isolatedModules: true + emitDecoratorMetadata: true + ESM emit), which otherwise rejects a value-imported type referenced in a decorated signature with TS1272. The related entity stays a value import (the () => Entity thunk needs it at runtime).
Migrations
| Variable | Default | Description |
|----------|---------|-------------|
| MIGRATION_FORMAT | split | Migration file layout. split writes <ts>_<name>.up.sql + <ts>_<name>.down.sql. combined writes a single <ts>_<name>.sql with -- migrate:up / -- migrate:down markers (the dbmate / node-pg-migrate convention) |
| MIGRATIONS_TABLE | sqlml_migrations | Table name used to track applied migrations |
| OFFLINE_GENERATION | false | When true, sqlml generate builds entity files directly from DBML — no database connection required. Required for CI environments that don't run a Postgres instance during generation. See Offline generation |
Web UI
| Variable | Default | Description |
|----------|---------|-------------|
| UI_PORT | 4200 | Port for the Web UI server |
| UI_SERVER_PORT | — | Override server port (takes precedence over UI_PORT) |
| UI_THEME | dark | UI color theme: dark or light |
| UI_OPEN_BROWSER | true | Automatically open browser when running sqlml ui |
Example
# Connection
ORM_TYPE=typeorm
DB_TYPE=postgres
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mydatabase
DB_USER=postgres
DB_PASSWORD=
DB_SCHEMA=public
DB_SSL=false
# File Paths
SCHEMA_DIR=./sqlml/src/lib/schema
SQL_DIR=./sqlml/src/lib/sql
ENTITIES_DIR=./sqlml/src/lib/entities
MIGRATIONS_DIR=./sqlml/src/lib/migrations
# NAMESPACE_BY_SCHEMA=true # multi-schema monorepo (see Multi-schema workspaces)
# Migrations
# MIGRATION_FORMAT=combined # single-file -- migrate:up / -- migrate:down
# Offline generation (CI without Postgres)
# OFFLINE_GENERATION=true
# INDEX_FILE_MODE=cumulative # multi-schema barrel
# Table Filtering
# SKIP_TABLES=migrations,seeds
# ONLY_TABLES=users,posts,comments
# Naming Conventions
CASE_FILE=param
CASE_ENTITY=pascal
CASE_PROPERTY=camel
# Web UI
UI_PORT=4200
# UI_SERVER_PORT=4200
UI_THEME=dark
UI_OPEN_BROWSER=true
# Generation Options
PROPERTY_VISIBILITY=none
STRICT_MODE=none
LAZY=false
ACTIVE_RECORD=false
RELATION_IDS=true
SKIP_SCHEMA=false
GENERATE_CONSTRUCTOR=true
PLURALIZE_NAMES=true
INDEX_FILE=true
EXPORT_TYPE=named
EOL=LFInline comments are supported. Lines like
CASE_ENTITY=pascal # class Productare parsed correctly — everything after a whitespace-prefixed#is treated as a trailing comment. Use this to annotate values without losing them. Quoted values ("foo # bar") preserve embedded#characters.
Custom entity decorators (CDC and beyond)
DBML can drive arbitrary TypeScript decorators on the generated entity, allowing schema files to carry runtime configuration alongside the structural definition. The original use-case is Change Data Capture (CDC) — annotating tables and columns with replica-identity, snapshot, and PII-exclusion settings — but the mechanism is decorator-agnostic: any TypeScript decorator that takes an object literal can be wired up.
Quick example
packages/sqlml/src/lib/schema/users.dbml:
/*@CdcTable({
"replicaIdentity": "full",
"snapshotMode": "initial",
"description": "User accounts CDC stream"
})*/
Table users {
id uuid [pk, default: `gen_random_uuid()`]
username varchar(50) [not null, unique] /*@CdcInclude()*/
email varchar(255) [not null] /*@CdcExclude({"reason": "PII — only Notification svc needs this"})*/
password_hash varchar(255) /*@CdcExclude({"reason": "Secret — never leave the database"})*/
/*@CdcInclude()*/
full_name varchar(120)
created_at timestamptz [default: `now()`]
}.sqlml/decorators.json:
{
"$schema": "https://sqlml.dev/decorators.schema.json",
"decorators": [
{
"name": "CdcTable",
"package": "@your-org/cdc",
"import": "named",
"appliesTo": "table",
"argsSchema": {
"properties": {
"replicaIdentity": { "type": "string", "enum": ["default", "full", "index", "nothing"] },
"snapshotMode": { "type": "string", "enum": ["initial", "initial_only", "never", "when_needed", "schema_only", "schema_only_recovery"] }
}
}
},
{ "name": "CdcExclude", "package": "@your-org/cdc", "import": "named", "appliesTo": "column", "argsSchema": { "required": ["reason"], "properties": { "reason": { "type": "string" } } } },
{ "name": "CdcInclude", "package": "@your-org/cdc", "import": "named", "appliesTo": "column" }
]
}Generated users.entity.ts:
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { CdcExclude, CdcInclude, CdcTable } from '@your-org/cdc';
@CdcTable({ replicaIdentity: 'full', snapshotMode: 'initial', description: 'User accounts CDC stream' })
@Entity('users')
export class Users {
@PrimaryColumn('uuid')
id!: string;
@CdcInclude()
@Column('varchar', { length: 50 })
username!: string;
@CdcExclude({ reason: 'PII — only Notification svc needs this' })
@Column('varchar', { length: 255 })
email!: string;
@CdcExclude({ reason: 'Secret — never leave the database' })
@Column('varchar', { length: 255 })
passwordHash!: string;
@CdcInclude()
@Column('varchar', { length: 120 })
fullName!: string;
@Column('timestamptz')
createdAt!: Date;
}Marker syntax
| Form | Where it goes | Binds to |
|------|---------------|----------|
| /*@DecoratorName({ "key": "value" })*/ on its own line before a Table declaration | Table-level | The next Table |
| /*@DecoratorName({ … })*/ on its own line before a column line | Column-level | The next column |
| /*@DecoratorName({ … })*/ inline at the end of a column line | Column-level | The same column |
| Multiple markers stacked | Order is preserved | First-to-last in source order |
| /*@DecoratorName*/ (no parens) | Renders as @DecoratorName | — |
| /*@DecoratorName()*/ (empty parens) | Renders as @DecoratorName() | — |
Argument bodies are strict JSON — keys must be double-quoted, single-quoted strings are rejected. This matches what the resulting TypeScript object literal needs anyway, so a marker that parses cleanly is guaranteed to round-trip into valid generated code.
Hard-fail validation
Generation aborts (exit code 1, no files written) when any of these are true:
| Code | Trigger |
|------|---------|
| INVALID_DECORATOR_JSON | Marker body fails JSON parse |
| ORPHAN_DECORATOR | Marker isn't attached to a table or column |
| UNKNOWN_DECORATOR | Marker name not registered in decorators.json (with a did-you-mean hint) |
| WRONG_POSITION | appliesTo: 'table' decorator placed on a column or vice versa |
| UNKNOWN_COLUMN | Column-level marker references a column the table doesn't declare |
| CDC_EXCLUDE_REASON_REQUIRED | @CdcExclude missing a non-empty reason field |
| CONFLICTING_INCLUDE_EXCLUDE | Same column has both @CdcInclude and @CdcExclude |
| INVALID_DECORATOR_ARGS | Argument shape violates the decorator's argsSchema |
Failures are collected and reported in one batch, so a single run shows every problem the developer needs to fix.
Atomic generation
Entity files are rendered into memory first, then flushed to disk only when every entity has been built successfully. A failure mid-render aborts before any file is touched, so the workspace never ends up in a half-emitted state where some entities reflect the new DBML and others reflect the previous run.
Decorator imports
The CLI deduplicates and alphabetises imports per package across the entity. Re-running generation on an unchanged DBML produces byte-identical output, so the file is safe to commit and review in pull requests.
Backwards-compatibility
When .sqlml/decorators.json is absent, the entire decorator pipeline is skipped and entity generation behaves exactly as it did in 0.3.x. Adopting CDC is opt-in per workspace.
Advanced schema modeling
DBML can't natively express every Postgres construct — but rather than dropping back to hand-authored migrations, sqlml declares the missing ones with block-comment markers. Because markers are comments, a .dbml that uses them stays parseable by standard tooling (dbdiagram.io, dbdocs, @dbml/core) — those tools simply ignore them, while sqlml round-trips each construct into SQL, migrations, and (where applicable) entities.
| Construct | How it's declared | Section |
|-----------|-------------------|---------|
| CHECK constraints | /*@Check(…)*/, /*@CheckIn(…)*/ | CHECK constraints |
| FK referential actions | native Ref … [delete: …, update: …] | Foreign key referential actions |
| Partial index (WHERE) | /*@IndexWhere(…)*/ | Partial, expression & covering indexes |
| Partial UNIQUE index | /*@UniqueWhere(…)*/ | Partial, expression & covering indexes |
| Expression / functional index | /*@IndexExpr(…)*/ | Partial, expression & covering indexes |
| Covering index (INCLUDE) | /*@IndexInclude(…)*/ | Partial, expression & covering indexes |
| Index access method (USING) | /*@IndexUsing(…)*/ | Index access method |
| Extension-provided types | inferred, or /*@Extension(…)*/ | Extension types |
| Table partitioning | /*@Partition(…)*/ | Declarative partitioning |
Everything here is additive and opt-in: a schema that uses none of these markers generates byte-identically to before — no drift on upgrade. Each construct fails fast at generate time (exit 1, no files written) on a malformed marker, with a clear, line-tagged message. Custom entity decorators use the same marker mechanism — see Custom entity decorators.
Minimum version: CHECK constraints — 0.4.0. FK referential actions, partial/expression/covering indexes, table partitioning, and non-interactive mode — 0.5.0. Index access methods (
@IndexUsing), extension types (@Extension), andRelation<T>wrappers — 0.5.1. Postgres array columns, thetype Relationimport (TS1272 fix), and fullOFFLINE_GENERATIONparity (relations, many-to-many, indexes, enums) — 0.5.2.
Marker & decorator reference
The complete set of block-comment markers, their argument shapes, and where each one lands. All arguments are strict JSON (keys double-quoted); the short string form is sugar where noted. Placement is where the marker must sit; emits is what it produces and in which paths.
| Marker | Argument(s) | Placement | Emits | Paths | Since |
|--------|-------------|-----------|-------|-------|-------|
| @Check | "<sql expr>" or {"name","expr"} | on a column, or above the Table (multi-column) | CONSTRAINT <name> CHECK (<expr>) + @Check(<name>, <expr>) on the entity | generate · migration · entity | 0.4.0 |
| @CheckIn | "a, b, c" or {"name","values"} | on a column | CHECK ("<col>" IN ('a', 'b', 'c')) + @Check | generate · migration · entity | 0.4.0 |
| @IndexWhere | {"index","where"} | above the Table | CREATE INDEX … WHERE <pred> (partial index) | generate · migration | 0.5.0 |
| @UniqueWhere | {"index","where"} | above the Table | CREATE UNIQUE INDEX … WHERE <pred> | generate · migration | 0.5.0 |
| @IndexExpr | {"index","expr"} | above the Table | CREATE INDEX … (<expr>) (functional index) | generate · migration | 0.5.0 |
| @IndexInclude | {"index","columns"} (array or "a, b") | above the Table | CREATE INDEX … INCLUDE (<cols>) (covering) | generate · migration | 0.5.0 |
| @IndexUsing | {"index","method"} — BTREE/HASH/GIST/SPGIST/GIN/BRIN | above the Table | CREATE INDEX … USING <method> (…) | generate · migration | 0.5.1 |
| @Extension | "postgis" or {"name"} | anywhere in the file | CREATE EXTENSION IF NOT EXISTS "<ext>"; ahead of CREATE TABLE | generate | 0.5.1 |
| @Partition | {"by":"HASH","key","modulus"} | above the Table | PARTITION BY HASH (…) parent + PARTITION OF children | generate · migration | 0.5.0 |
The @Index* markers all reference an index by its declared name, so the target index must exist in the table's Indexes { } block (and therefore carry an explicit name). Multiple @Index* markers can target the same index (e.g. @IndexUsing + @IndexWhere for a partial GIST index). Predicates and expressions are passed through verbatim (trusted author input, same model as @Check).
Not markers, but related generated-output behaviour:
| Construct | How it's declared | Result |
|-----------|-------------------|--------|
| FK referential actions | native DBML Ref … [delete: …, update: …] | ON DELETE … / ON UPDATE … |
| Extension types | inferred from the column type (geography/geometry → postgis, citext, hstore, ltree, vector) | CREATE EXTENSION IF NOT EXISTS … (no marker needed) |
| double precision vs real | DBML type keyword | bare float → double precision; real/float4 → real; float8/double → double precision |
| Relation<T> wrappers | automatic on every generated relation property | product!: Relation<Products> / images!: Relation<ProductImages[]> — TDZ-safe under emitDecoratorMetadata |
| Custom decorators | user-defined in .sqlml/decorators.json + /*@Name({...})*/ | arbitrary @Name({...}) on the entity (e.g. CDC) — see Custom entity decorators |
Fail-fast codes: MALFORMED_PREDICATE (bad JSON / unbalanced predicate / unknown @IndexUsing method), UNKNOWN_INDEX (@Index* names a non-existent index), DUPLICATE_INDEX_WHERE (two markers of one kind on one index), MALFORMED_EXTENSION (bad @Extension argument), UNKNOWN_FK_ACTION, PARTITION_KEY_MISSING_FROM_PK.
Array columns
Declare a Postgres array column in DBML with a quoted type — "text[]", "uuid[]", "varchar(50)[]", or multi-dimensional "text[][]" (the quotes keep @dbml/core and dbdiagram.io happy):
Table posts {
id uuid [pk]
hashtags "text[]"
media_ids "uuid[]" [not null]
tags "varchar(50)[]"
}The generated SQL emits the native array type, and the entity gets a TypeORM array column with a T[] property type — identical on the introspection and OFFLINE_GENERATION paths:
CREATE TABLE IF NOT EXISTS "posts" (
"id" uuid NOT NULL,
"hashtags" text[],
"media_ids" uuid[] NOT NULL,
"tags" varchar(50)[],
PRIMARY KEY ("id")
);@Column('text', { name: 'hashtags', array: true, nullable: true })
hashtags!: string[] | null;
@Column('uuid', { name: 'media_ids', array: true })
mediaIds!: string[];
@Column('varchar', { name: 'tags', array: true, length: 50, nullable: true })
tags!: string[] | null;Array columns are PostgreSQL-only — a text[] on a MySQL/MSSQL target is rejected up front with a clear, line-tagged error rather than emitting invalid SQL. A malformed array type ("text[", "[]", "text[]x") is likewise rejected with a precise diagnostic, not a blanket "Failed to convert DBML to SQL". (Since 0.5.2.)
CHECK constraints
DBML has no native CHECK syntax, so sqlml declares CHECK constraints with block-comment markers — the same mechanism used by custom entity decorators. Because markers are comments, a .dbml that uses them stays parseable by standard tooling (dbdiagram.io, dbdocs, @dbml/core) — those tools simply ignore the markers.
Table orders {
id int [pk]
status varchar(16) [not null] /*@CheckIn("open, paid, cancelled")*/
amount numeric [not null] /*@Check("amount >= 0")*/
key bytea /*@Check("octet_length(key) BETWEEN 32 AND 64")*/
}
/*@Check({"name": "res_window", "expr": "ends_at > starts_at"})*/
Table reservations {
starts_at timestamptz [not null]
ends_at timestamptz [not null]
}@Check("<expr>")— a raw SQL predicate, passed through verbatim (trusted author input).@CheckIn("a, b, c")— enum-membership sugar that expands to"<col>" IN ('a', 'b', 'c')with each value single-quote-escaped. Must sit on a column.- Object form —
@Check({"name": "...", "expr": "..."})/@CheckIn({"name": "...", "values": "a, b"}). A table-level marker (placed immediately above theTableheader) carries a multi-column predicate.
Multiple markers are allowed per column and per table.
Naming
Every emitted constraint is named. Provide a name to pin it (load-bearing for diffs, drops, and matching a constraint that already exists on a live database), otherwise sqlml derives a deterministic default: <table>_<column>_check for column markers and <table>_check for table markers, with a numeric suffix (_check2, _check3, …) on collision.
Where CHECKs appear
| Path | Output |
|------|--------|
| sqlml generate | Inline CONSTRAINT <name> CHECK (…) inside the generated CREATE TABLE |
| sqlml diff --format sql / migration:from-dbml | Added/removed/changed CHECKs as ADD/DROP CONSTRAINT in the up/down migration (a changed predicate under the same name is a DROP + ADD) |
| TypeORM entities | A @Check("<name>", "<expr>") decorator on the generated class |
Output is deterministic and byte-stable: identical DBML produces identical SQL, and a CHECK-free schema generates exactly as it did before this feature — the whole feature is additive and opt-in.
NULL semantics
SQL CHECK uses three-valued logic — the predicate passes when it evaluates to NULL. sqlml does not inject IS NOT NULL, so a marker on a nullable column still admits NULL. A @CheckIn on a NOT NULL column is therefore a strict enum; on a nullable column it also admits NULL.
Production safety (Postgres)
Adding a CHECK to a populated table normally takes an ACCESS EXCLUSIVE lock and full-table-scans to validate. Two opt-in flags make this safe:
# Fast add (brief lock), then validate under a weaker lock
sqlml migration:from-dbml add_amount_check --online
# ALTER TABLE … ADD CONSTRAINT … CHECK (…) NOT VALID;
# ALTER TABLE … VALIDATE CONSTRAINT …;
# Tolerate a constraint that already exists (e.g. from a prior hand-written migration)
sqlml migration:from-dbml add_amount_check --idempotent
# DO $$ BEGIN ALTER TABLE … ADD CONSTRAINT … CHECK (…);
# EXCEPTION WHEN duplicate_object THEN NULL; END $$;The flags compose. Default output stays single-statement, so existing adopters see no change unless they opt in.
Adoption sequence for a database that already has the constraint: annotate the DBML → set the marker name to match the constraint already in the database (often Postgres' auto-name <table>_<col>_check) so the diff recognises it → generate with --idempotent → the generated ADD is a no-op instead of a duplicate-object error.
@CheckIn vs native enum
@CheckIn emits a CHECK … IN (…) rather than a Postgres CREATE TYPE … AS ENUM. This is intentional: adding a value later is a cheap constraint swap with no type-dependency ordering, where a native enum requires ALTER TYPE … ADD VALUE. Use a DBML Enum block if you specifically want a native enum type.
Validation (fail-fast at generate time)
Generation aborts with a clear, line-tagged message when an expression has unbalanced parentheses or quotes, a @CheckIn value contains a single quote, or a marker is not attached to a table/column.
Foreign key referential actions
DBML expresses ON DELETE / ON UPDATE natively — no marker needed. sqlml emits the action clause from the delete: / update: settings on a Ref (standalone or inline):
Ref: posts.user_id > users.id [delete: cascade, update: restrict]
Table comments {
post_id uuid [ref: > posts.id, delete: cascade]
}| Keyword | Emitted |
|---------|---------|
| cascade | ON DELETE CASCADE / ON UPDATE CASCADE |
| set null | SET NULL |
| restrict | RESTRICT |
| no action | NO ACTION |
| set default | SET DEFAULT |
| Path | Output |
|------|--------|
| sqlml generate | … REFERENCES "users" ("id") ON DELETE CASCADE (via @dbml/core) |
| migration:from-dbml | An added/removed/changed action as ADD/DROP CONSTRAINT in the up/down migration (a changed action is DROP + ADD) |
Absent settings emit no action clause — byte-identical to before. --idempotent (Postgres) wraps the FK ADD in a duplicate_object-tolerant DO block for adopting a database that already has the constraint. An unknown action keyword fails fast (UNKNOWN_FK_ACTION).
Partial, expression & covering indexes
Standard DBML can't express a partial-index WHERE, a functional index, or INCLUDE columns — and a [where: …] setting would make the .dbml unparseable by other tooling. So, like CHECK, these are declared with table-level block-comment markers that reference an index by its declared name:
/*@IndexWhere({"index":"idx_widgets_active","where":"revoked_at IS NULL"})*/
/*@IndexExpr({"index":"idx_lower_email","expr":"lower(email)"})*/
/*@IndexInclude({"index":"idx_cover","columns":["email","name"]})*/
Table widgets {
owner_id uuid [not null]
email varchar(255)
name varchar(255)
revoked_at timestamptz
Indexes {
owner_id [name: 'idx_widgets_active']
email [name: 'idx_lower_email']
owner_id [name: 'idx_cover']
}
}@IndexWhere— partial index →CREATE INDEX … WHERE ….@UniqueWhere— partial UNIQUE index →CREATE UNIQUE INDEX … WHERE ….@IndexExpr— expression/functional index →CREATE INDEX … ON t (lower(email)).@IndexInclude— covering index →CREATE INDEX … INCLUDE (…).
The named index must exist in the table's Indexes { } block (a partial index must therefore carry an explicit name). The predicate/expression is passed through verbatim (trusted author input, same model as @Check).
| Path | Output |
|------|--------|
| sqlml generate | The attribute spliced into the emitted CREATE INDEX |
| migration:from-dbml | A new/changed attribute reshapes via DROP INDEX + CREATE INDEX … (Postgres has no ALTER INDEX … SET WHERE) |
--online (Postgres) emits CREATE INDEX CONCURRENTLY / DROP INDEX CONCURRENTLY — caveat: CONCURRENTLY cannot run inside a transaction block. Fail-fast codes: UNKNOWN_INDEX, DUPLICATE_INDEX_WHERE, MALFORMED_PREDICATE. An unmarked index emits exactly as before.
Index access method (USING)
A geography column needs a GIST index for ST_DWithin; full-text needs GIN. DBML can't express the access method, so it's declared with a marker referencing the index by name — composable with @IndexWhere / @IndexInclude:
/*@IndexUsing({"index":"idx_pins_location","method":"GIST"})*/
Table pins {
id uuid [pk]
location geography
Indexes {
location [name: 'idx_pins_location']
}
}This emits CREATE INDEX "idx_pins_location" ON "pins" USING GIST ("location") in both the generate and migration:from-dbml paths. Supported methods: BTREE, HASH, GIST, SPGIST, GIN, BRIN (case-insensitive; an unknown method fails fast with MALFORMED_PREDICATE). An unmarked index emits exactly as before.
Extension types (CREATE EXTENSION)
A column whose type comes from a Postgres extension — PostGIS geography / geometry, citext, hstore, ltree, vector — needs the extension installed before its CREATE TABLE runs, or the generated SQL (and the introspection scratch DB) fails with type "geography" does not exist. sqlml infers the requirement from the column type and prepends a CREATE EXTENSION IF NOT EXISTS preamble:
Table pins {
id uuid [pk]
location geography -- ⇒ CREATE EXTENSION IF NOT EXISTS "postgis";
}For a type sqlml can't infer, declare it explicitly with a marker (string or {"name":"…"} form), placeable anywhere in the file:
/*@Extension("postgis")*/The preamble is emitted ahead of CREATE TABLE in the generate SQL (Postgres only). Extensions are de-duplicated and sorted, so output is byte-stable; a schema with no extension type and no marker is byte-identical to before. Inferred mappings: geography/geometry → postgis, citext → citext, hstore → hstore, ltree → ltree, vector → vector.
Declarative partitioning
A hash-partitioned table is declared with a table-level marker. v1 supports HASH (the common sharding case); RANGE / LIST are recognised and fail fast as a documented follow-up.
/*@Partition({"by":"HASH","key":"viewer_id","modulus":16})*/
Table ledger {
id uuid [not null]
viewer_id uuid [not null]
amount bigint [not null]
Indexes {
(viewer_id, id) [pk] // PK MUST include the partition key
}
}Emits the parent plus N deterministically-named children:
CREATE TABLE "ledger" ( … PRIMARY KEY ("viewer_id", "id") ) PARTITION BY HASH ("viewer_id");
CREATE TABLE IF NOT EXISTS "ledger_p0" PARTITION OF "ledger" FOR VALUES WITH (MODULUS 16, REMAINDER 0);
-- … _p1 … _p15Postgres requires every PRIMARY KEY / UNIQUE on a partitioned table to include the partition key — a violation fails fast (PARTITION_KEY_MISSING_FROM_PK). Changing the modulus is a non-trivial data reshape: sqlml emits a loud TODO[sqlml] note rather than a silently-wrong diff. Note: partitioned tables behave differently under logical replication (publish via the partition root vs. leaves) — review your CDC/replication setup; sqlml does not manage that.
Non-interactive mode
Generation and migration commands prompt for confirmation by default. In CI, pass --yes (alias --non-interactive) — or set CI=true — to proceed without prompting, instead of piping yes:
sqlml migration:from-dbml add_widgets --schema demo --yes
CI=true sqlml migration:upForward-only prompts (create migration files, run pending migrations) are auto-accepted by --yes/--non-interactive or CI=true. Destructive prompts (migration:revert / migration:down, migration:force) require the explicit flag — CI alone is deliberately not enough, so a stray CI run can't drop data.
Offline generation
Set OFFLINE_GENERATION=true to build entity files straight from the DBML without ever touching the database. The generator parses the DBML AST, produces TypeORM Entity records in memory, runs them through the same naming/customization pipeline the introspection flow uses, and emits TypeScript files — all dependency-free.
OFFLINE_GENERATION=true# CI workflow that doesn't run Postgres
npx sqlml generate users
# → entities/users/users.entity.ts (no DB connection attempted)As of 0.5.2 the offline path runs the same relation-builders, naming strategy, and renderer as the database-introspection flow — only the source of the model differs (DBML AST vs DB catalog) — so it is a full-parity, first-class replacement. What's covered:
- Tables →
@Entityclasses with schema annotation (a table without an explicit DBML schema defaults to the configured connection schema;SKIP_SCHEMAstrips it) - Columns →
@Columnwith type, length, precision/scale, nullable, unique, default - Array columns —
"text[]"/"uuid[]"/"varchar(50)[]"→@Column('<base>', { array: true })typedT[](see Array columns) - Enum columns — a column typed with a DBML
Enum→@Column('enum', { enum: […], enumName })with a string-literal-union TS type - Relations (
ref:) —@ManyToOne/@OneToMany/@OneToOne+Relation<T>on both sides,@JoinColumn, referential actions (onDelete/onUpdate), and@RelationIdwhenRELATION_IDS=true - Many-to-many — a junction table (all-PK columns that are its two FKs) collapses to
@ManyToMany+@JoinTable, with the junction entity removed - Indexes —
Indexes { … }blocks →@Index(name, [cols], { unique }); composite primary keys →@PrimaryColumns - CHECK markers →
@Check;pk+default: gen_random_uuid()→@PrimaryGeneratedColumn('uuid'); DBMLnote:→ JSDoc comment - TypeMapper-driven type resolution, identical to the migration emitter
A few column-level details are inherently DBML-vs-database-catalog differences, so offline output is not byte-identical to introspection for every column (it still compiles cleanly under strict + isolatedModules + emitDecoratorMetadata): @Check predicates keep the author's DBML spelling rather than Postgres' canonical normalised form, double precision / real omit the catalog-reported precision, and a uuid [pk, default: …] renders as @PrimaryGeneratedColumn('uuid') (app-side) rather than @PrimaryColumn with a DB default.
For the database-connected flow (the default), OFFLINE_GENERATION=false keeps the "apply SQL → introspect → emit" pipeline.
Note:
OFFLINE_GENERATIONis read from.sqlml/.config-sqlml, not the environment —OFFLINE_GENERATION=true sqlml generate …(env-var form) is ignored and falls through to the database flow.
Cumulative barrel
When INDEX_FILE_MODE=cumulative the barrel (index.ts) is rebuilt from a filesystem scan of the entities directory rather than from the current generation run. Useful in multi-schema workspaces (NAMESPACE_BY_SCHEMA=true) where each sqlml generate <schema> call only sees one schema's entities, yet the per-schema barrel needs to re-export everything emitted into that subdirectory across runs.
INDEX_FILE_MODE=cumulativeThe scan ignores index.ts itself, *.d.ts declaration files, and any .ts file that doesn't export a class. Output is alphabetically sorted by file base name so two consecutive runs against an unchanged tree produce byte-identical barrels.
Atomic generation
Entity files are rendered into memory first, then flushed to disk only after every entity has been built successfully. A failure mid-render aborts before any file is touched — the workspace never ends up partially emitted, where some entities reflect the new DBML and others reflect the previous run.
This is enforced unconditionally; there is no way to opt out.
Column documentation from DBML notes
Any note: '…' on a DBML column is surfaced as a JSDoc comment above the matching @Column decorator in the generated entity:
Table orders {
id uuid [pk]
buyer_id uuid [not null, note: 'CDC ref → identity-service.users.id']
product_id uuid [not null, note: 'CDC ref → catalog-service.products.id']
amount bigint [not null]
}@Entity('orders')
export class Orders {
@PrimaryColumn('uuid')
id!: string;
/** CDC ref → identity-service.users.id */
@Column('uuid', { name: 'buyer_id' })
buyerId!: string;
/** CDC ref → catalog-service.products.id */
@Column('uuid', { name: 'product_id' })
productId!: string;
@Column('bigint', { name: 'amount' })
amount!: number;
}Notes are escaped against */ sequences so the generated TypeScript is always parseable. Long notes wrap to a multi-line /** … */ block with leading * ; short ones collapse to /** text */.
Multi-schema workspaces
For monorepos that host several DBML schemas side-by-side — typically one per microservice — set NAMESPACE_BY_SCHEMA=true to isolate each schema's output under its own subdirectory:
NAMESPACE_BY_SCHEMA=true
MIGRATION_FORMAT=combined
SCHEMA_DIR=./packages/sqlml/src/lib/schema
ENTITIES_DIR=./packages/sqlml/src/lib/entities
MIGRATIONS_DIR=./packages/sqlml/src/lib/migrations
SQL_DIR=./packages/sqlml/src/lib/sqlpackages/sqlml/src/lib/
├── schema/
│ ├── catalog-service.dbml
│ └── identity-service.dbml
├── entities/
│ ├── catalog-service/ # class Product, class Outbox, ...
│ └── identity-service/ # class User, class Outbox (no collision)
├── migrations/
│ ├── catalog-service/000001_add_initial.sql
│ └── identity-service/000001_add_initial.sql
├── sql/
│ ├── catalog-service/catalog-service.sql
│ └── identity-service/identity-service.sql
└── .sqlml/snapshots/
├── catalog-service/000000.dbml
└── identity-service/000000.dbmlWhat this gives you:
- No class-name collisions. Tables that share a name across services (e.g.
outboxfor the transactional-outbox pattern) compile cleanly because eachclass Outboxlives in its own module. - Per-schema migration history.
getLatestVersion()is computed inside the namespace, socatalog-service's000005does not affectidentity-service's numbering. - Per-schema snapshots.
migration:from-dbml --schema catalog-servicediffs againstcatalog-service's own snapshot history. - Reproducible CI.
MIGRATION_FORMAT=combinedproduces a single.sqlper migration that any external runner (dbmate, node-pg-migrate, or your own_migrations-table runner) can apply directly.
--schema argument forms — all three resolve to the same catalog-service namespace:
| Form | Resolves to |
|------|-------------|
| --schema catalog-service | <SCHEMA_DIR>/catalog-service.dbml |
| --schema catalog-service.dbml | <SCHEMA_DIR>/catalog-service.dbml |
| --schema /abs/path/to/catalog-service.dbml | exact path; namespace = basename without .dbml |
Combined-format migration example:
-- migrate:up
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL
);
-- migrate:down
DROP TABLE products;Marker rules: -- migrate:up and -- migrate:down must be lowercase, each appears exactly once, UP precedes DOWN. An empty DOWN body is permitted for irreversible migrations — runners treat it as a no-op rollback and emit a warning.
Default behavior is unchanged when these flags are absent: flat layout with split .up.sql / .down.sql files.
Production deployment
sqlml is dev/CI tooling — the CLI itself is never installed on production runtime images. Generation produces three artefacts that ship with your application code: entity TypeScript files, the per-schema .sql migrations, and an optional auto-generated barrel. The runtime only needs the entities plus a thin migration runner that consumes the .sql files.
[DEV TIME] [BOOT or CI TIME] [RUNTIME]
sqlml migration:from-dbml → runner reads .sql files → forRoot({
produces .sql applies them to the DB synchronize: false,
migrationsRun: false,
})What lives in your production image:
pg(or your TypeORM driver)typeorm- The compiled entity files (e.g.
@your-org/sqlml/catalog-service/users.entity.js) - The generated
.sqlmigration files - Your migration runner (a thin module — code below)
What does NOT need to be in the image:
@sqlml/cli(~100 MB of database driver dependencies)@dbml/coreprisma(unless you use Prisma generation in production)
TypeORM configuration
Configure TypeOrmModule.forRoot so neither synchronize nor TypeORM's own migration runner ever touches the schema. The .sql files own the schema; TypeORM owns the connection.
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (cs: ConfigService) => ({
type: 'postgres',
url: cs.getOrThrow('DATABASE_URL'),
entities: [Product, Category, Outbox /* … */],
// Never `true` — it generates DDL at runtime. Use generated .sql instead.
synchronize: false,
// TypeORM's built-in migration runner expects .ts MigrationInterface
// classes; sqlml emits .sql, so we run them ourselves (see below).
migrationsRun: false,
migrations: [],
}),
})Migration runner
A small runner — typically ~80 lines — reads .sql files from the migrations directory and applies pending ones inside a transaction. Tracks applied migrations in a _sqlml_migrations table per database. The combined format (-- migrate:up / -- migrate:down) is split on the up marker before execution.
// libs/database/src/migration-runner.ts
import { DataSource } from 'typeorm';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
export interface RunMigrationsOptions {
dataSource: DataSource;
migrationsDir: string;
tableName?: string;
dryRun?: boolean;
}
export async function runMigrations(opts: RunMigrationsOptions): Promise<void> {
const { dataSource, migrationsDir, tableName = '_sqlml_migrations', dryRun = false } = opts;
const queryRunner = dataSource.createQueryRunner();
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "${tableName}" (
version VARCHAR(6) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
checksum VARCHAR(64) NOT NULL
)
`);
// Advisory lock — N pods booting in parallel still apply migrations once.
await queryRunner.query("SELECT pg_advisory_lock(hashtext('sqlml_migrations'))");
try {
const applied = await queryRunner.query(`SELECT version FROM "${tableName}"`);
const appliedSet = new Set(applied.map((r: { version: string }) => r.version));
const files = fs.readdirSync(migrationsDir)
.filter(f => /^\d{6}_.+\.sql$/.test(f))
.sort();
const pending = files.filter(f => !appliedSet.has(f.slice(0, 6)));
if (pending.length === 0) {
console.log('No pending migrations.');
return;
}
for (const file of pending) {
const version = file.slice(0, 6);
const name = file.slice(7, -4);
const body = fs.readFileSync(path.join(migrationsDir, file), 'utf-8');
// Split combined-format migrations on the -- migrate:up marker.
const upMatch = body.match(/-- migrate:up\s*\n([\s\S]*?)(?=\n-- migrate:down|$)/);
if (!upMatch) {
throw new Error(`Migration ${file} is missing the -- migrate:up marker`);
}
const upSql = upMatch[1].trim();
console.log(`[migrate:up] ${file}`);
if (dryRun) continue;
await queryRunner.startTransaction();
try {
await queryRunner.query(upSql);
const checksum = crypto.createHash('sha256').update(body).digest('hex');
await queryRunner.query(
`INSERT INTO "${tableName}" (version, name, checksum) VALUES ($1, $2, $3)`,
[version, name, checksum]
);
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
}
}
} finally {
await queryRunner.query("SELECT pg_advisory_unlock(hashtext('sqlml_migrations'))");
await queryRunner.release();
}
}For split-format migrations (<ts>_<name>.up.sql + <ts>_<name>.down.sql files), drop the marker-split step and read the .up.sql file directly.
Where to run it — two strategies
| Strategy | When migrations run | Trade-off |
|----------|--------------------|-----------|
| Boot-time | App startup, before forRoot initialises | Simple, every deploy auto-migrates. Slower cold start; N pods booting at once all attempt migrate (the lock keeps it correct, just busy) |
| CI/CD step | Separate job before service rollout | Schema change is an explicit deploy step, with its own logs. If migrations fail the service deploy never runs. Best fit for production. Required for zero-downtime expand-then-contract migrations |
Boot-time (NestJS example)
Run the migration on a temporary DataSource, then start the app on a second one. The two are independent so the migration connection can use elevated DDL privileges that the service user shouldn't have.
// apps/catalog-service/src/main.ts
import { NestFactory } from '@nestjs/core';
import { DataSource } from 'typeorm';
import { runMigrations } from '@your-org/database';
import { AppModule } from './app/app.module';
async function bootstrap() {
const migrationDs = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
});
await migrationDs.initialize();
try {
await runMigrations({
dataSource: migrationDs,
migrationsDir: require.resolve('@your-org/sqlml/migrations/catalog-service'),
});
} finally {
await migrationDs.destroy();
}
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT || 3000);
}
bootstrap();CI/CD step (recommended for production)
Run migrations as a separate workflow job that gates the service deploy. Failure stops the rollout.
# .github/workflows/deploy-catalog.yml
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx tsx scripts/migrate.ts catalog-service
env:
DATABASE_URL: ${{ secrets.CATALOG_DATABASE_URL }}
deploy:
needs: migrate
runs-on: ubuntu-latest
steps:
- run: kubectl apply -f catalog-service.yaml// scripts/migrate.ts
import { DataSource } from 'typeorm';
import { runMigrations } from '@your-org/database';
const service = process.argv[2];
const ds = new DataSource({ type: 'postgres', url: process.env.DATABASE_URL });
await ds.initialize();
try {
await runMigrations({
dataSource: ds,
migrationsDir: `packages/sqlml/src/lib/migrations/${service}`,
});
} finally {
await ds.destroy();
}For Nx monorepos, expose this as a service target:
{
"targets": {
"migrate": {
"executor": "nx:run-commands",
"options": {
"command": "tsx scripts/migrate.ts catalog-service",
"envFile": ".env"
}
}
}
}nx run catalog-service:migrateDay-to-day workflow
# 1. Edit the DBML schema
vi packages/sqlml/src/lib/schema/catalog-service.dbml
# 2. Generate the migration (no DB needed — OFFLINE_GENERATION=true)
npx sqlml migration:from-dbml add_wishlists --schema catalog-service
# → packages/sqlml/src/lib/migrations/catalog-service/000003_add_wishlists.sql
# 3. Code review the generated SQL alongside your DBML diff in the PR
# 4. Local verification
nx run catalog-service:migrate
nx run catalog-service:serve
# 5. Merge → CI runs migrate → CI deploys the serviceOperational checklist
| Rule | Why it matters |
|------|---------------|
| synchronize: false | TypeORM's runtime DDL emitter ignores the migration history, leaks schema drift, and can drop columns silently |
| migrationsRun: false | TypeORM expects .ts MigrationInterface; sqlml emits .sql, so the built-in runner is the wrong tool |
| pg_advisory_lock in the runner | N pods racing the same migration is a real production hazard during rolling deploys |
| One _sqlml_migrations table per database | Each microservice DB carries its own history (catalog_db, identity_db, …) — never share |
| One transaction per migration | Mid-migration failure rolls back cleanly; you never end up in a half-applied state |
| Per-schema migration directory (migrations/<schema>/) | Keeps service histories independent when NAMESPACE_BY_SCHEMA=true |
| Run before the app accepts traffic | The application code assumes the schema it was built against; a missing migration breaks the first request, not the deploy |
Supported Databases
| Database | Engine | Driver |
|----------|--------|--------|
| PostgreSQL | postgres | pg |
| MySQL | mysql | mysql2 |
| MariaDB | mariadb | mysql2 |
| SQLite | sqlite | sqlite3 |
| MSSQL | mssql | mssql |
| Oracle | oracle | oracledb |
Requirements
- Node.js >= 20.0.0 (tested on 20.x, 22.x, 23.x, 24.x, 25.x)
- TypeORM >= 0.3.20 or Prisma (peer dependency)
Links
License
MIT
