Skip to content

Repository files navigation

zero-migrate

Write once. Migrate everywhere.

Define a database migration once in TypeScript, then target PostgreSQL, MySQL, or SQLite without maintaining separate sets of handwritten SQL.

zero-migrate gives application teams:

  • a typed, JavaScript-first migration API;
  • portable tables, columns, indexes, constraints, and expressions;
  • explicit escape hatches for database-specific features;
  • staged PostgreSQL column renames with an explicit application cutover;
  • validation before a database connection is opened;
  • ownership and policy checks for platform-hosted migrations; and
  • an append-only migration history.

Portability is deliberate, not magical. Common database features share one API; vendor-only behavior is clearly marked and rejected on unsupported targets.

Pre-release: the JavaScript packages are not published to npm yet. Use the source-checkout setup in Getting started.

JavaScript security boundary: migration modules run as ordinary JavaScript with the permissions of the Node process. The Node API and CLI are not a sandbox. Run trusted modules only. A platform that accepts untrusted or generated source must isolate it outside the deployment process and use a reviewed Rust/custom-host workflow.

Ordered schema and data apply: on PostgreSQL, MySQL 8, and SQLite, the Node API and CLI execute schema changes, inserts, updates, deletes, and batched backfills in the order you authored them. Pending deletes and backfills require explicit approval. Approval is checked across the complete plan before any authored step runs, so a later unapproved data change cannot follow an already-committed earlier step from that plan. Matching completed steps skip on retry without renewed approval. SQLite executes the same operation categories through Node, the CLI, or Rust.

A portable migration

// migrations/20260715090000_create_orders.ts
import { ids, now, table, t } from "zero-migrate";

export const name = "create_orders";

export default {
  up() {
    table("orders").create({
      columns: {
        id: ids.typeId({ prefix: "ord" }).primaryKey(),
        total: t.numeric({ precision: 12, scale: 2 }).notNull(),
        // `status` is indexed below, so it is a bounded `t.string` (VARCHAR); a
        // MySQL index needs a bounded/prefix type, never unbounded `t.text()`.
        status: t.string({ length: 32 }).notNull().default("pending"),
        created_at: t.timestamp().notNull().default(now()),
      },
    });

    table("orders").index("orders_status_idx").add({
      on: ["status"],
    });

    table("orders").insert({
      rows: {
        id: "ord_01h455vb4pex5vsknk084sn02q",
        total: 0,
        status: "pending",
      },
    });
  },
};

ids.typeId({ prefix: "ord" }).primaryKey() stores public TypeID values and does not add a database default. The insert therefore supplies a matching TypeID explicitly.

The same migration can be checked for PostgreSQL, MySQL 8, or SQLite. If you choose a database-specific feature, you declare that choice in the migration so other targets fail with a clear validation error.

What you can use today

Workflow PostgreSQL MySQL 8 SQLite
TypeScript authoring and validation Yes Yes Yes
Node API / CLI schema and data apply Yes Yes Yes
Rust schema and data apply Yes Yes Yes
Ordered insert/update/delete/backfill Yes; no enabled user triggers except those managed by an online rename Yes, on InnoDB tables without user triggers Yes
Column rename workflow Staged online flow through Node or CLI Unsupported One table rebuild through Node, CLI, or Rust
Migration status Node and Rust Node and Rust Node and Rust
Detailed append-only history Node and Rust Not yet Not yet

MariaDB is not a supported target. The MySQL path targets MySQL 8.

Backfills capture a fixed terminal cursor before their first batch, save the last committed cursor, and stop at that original boundary. Rows inserted after the backfill starts are not guaranteed to be included; migrate them with a later migration. On every target, the cursor must be the table's complete, single-column primary key. Its values must remain unchanged for the entire backfill: moving a key behind the saved cursor can miss a row, while moving a processed key ahead can repeat it. SQLite additionally requires declared INTEGER or TEXT affinity with consistently typed live values. SQLite apply coordinates zero-migrate processes that target the same application database and refuses unsafe database or journal settings.

PostgreSQL column rename keeps the source and destination available while the application moves to the new name. In that PostgreSQL migration, the rename must be the only operation targeting its table. Operations on other tables may remain in the same migration. Put same-table follow-up work in a later migration and apply it only after resolution. The initial approved apply returns a pendingVersion; after every application instance has moved, resolve it with Node resolvePending() or CLI resolve. Apply resolution keeps the destination and drops the source. Abort keeps the source and drops the destination. Cleanup is all-or-nothing: if it fails, both columns and the managed rename trigger remain intact and the pending version stays valid. Apply and abort are terminal for that migration identity. To try again after abort, author a new migration with a new exported name. Status reports the old plan as aborted, and it does not satisfy dependsOn. The destination is nullable but keeps the source's exact live PostgreSQL type, including type modifiers. Required constraints, defaults, indexes, comments, and dependent objects need separate follow-up migrations. Dependencies on the source can block resolution and must be audited before rollout. See the online rename workflow.

JavaScript packages

Package Use it for
zero-migrate Writing typed migration modules
zero-migrate-cli Validating and applying ordered schema/data migrations, resolving PostgreSQL online renames, and reading status from Node or the CLI

The package names are reserved by this workspace but are not published to npm for this release.

Why zero-migrate

  • One authoring experience. Use the same TypeScript vocabulary across the supported databases.
  • Clear portability rules. Validation catches unsupported target features before deployment.
  • Safer platform operation. Ownership, policy, approval, database privileges, and migration history can be combined by a trusted host.
  • Reproducible review. Preview the exact structured changes before apply and detect changes to previously reviewed migrations.
  • JavaScript first, Rust when needed. Most users author and operate from TypeScript. Advanced hosts can use the documented public Rust types.

Start here

  1. Getting started: set up the pre-release and apply your first schema and data migration.
  2. Writing migrations: the complete TypeScript authoring guide.
  3. Choosing a database target: portability and database-specific features.
  4. CLI reference or Node API: choose how to run migrations.
  5. Operating migrations: approvals, deployment, history, recovery, and safe roll-forward practice.
  6. Documentation home: all guides and current limitations.

Rust hosts can continue with the Rust API, which documents the public types and supported backend workflows without requiring knowledge of the project internals.

Project status

zero-migrate is pre-release. The Rust workspace and the JavaScript packages are version 0.1.0. Expect API changes until the first stable release, pin the exact revision you deploy, and review the documented limitations before production use.

License

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages