First migration
Add a migrations directory, generate the first file, and apply one change.
Push applies the schema directly. It writes no SQL file. It is for a database you can recreate. Migrations are reviewed SQL files in git. They plan, lint, and apply per target. The default target is the one named default — a single database field in defineConfig, or the default entry under targets.
You have the files from Installation and First schema. That page ran okm push. Start from an empty database before you begin this page.
Recreate an empty database
Drop the database okm push used, then create it again. With the container from Installation:
docker exec okm-pg psql -U okm -d postgres -c 'DROP DATABASE IF EXISTS okm WITH (FORCE);'
docker exec okm-pg psql -U okm -d postgres -c 'CREATE DATABASE okm OWNER okm;'Or point DATABASE_URL at a fresh empty database. This page uses that empty database and the same project files.
If you skip this step and apply after push, Postgres reports that the relation already exists, because push already created notes and apply then runs the new SQL file:
error invalid: Migration 0001_init failed at step 1: relation "notes" already exists
Fix the step and run apply again. Finished steps are not repeated.Add migrations
The config from the first schema names schema and database. Add migrations.
import { defineConfig } from "okmodel/migrate";
export default defineConfig({
schema: "./schema.ts",
migrations: "./migrations",
database: process.env.DATABASE_URL!,
});Plan
bunx okm migrate plan init prints the SQL. It prints the lock each step takes. It writes nothing. stderr prints config: and the path of okmodel.config.ts. One target that answers adds the estimate on the lock line.
bunx okm migrate plan init-- class: expand
-- name: init
-- class: expand
-- kind: create-table
-- action: ddl
-- lock: ACCESS EXCLUSIVE on notes, new table
create table "public"."notes" (
"id" bigint generated always as identity not null,
"title" text not null,
constraint "notes_pkey" primary key ("id")
);The line is -- lock: ACCESS EXCLUSIVE on notes, new table.
Generate
bunx okm generate init writes migrations/0001_init.sql and migrations/0001_init.catalog.json. It prints the path of migrations/0001_init.sql. It also writes .gitattributes when that file is missing, and .okm/catalog.json, .okm/catalog.hash, and .okm/types.d.ts. The file records the lock. It does not add the estimate.
bunx okm generate init-- class: expand
-- name: init
-- class: expand
-- kind: create-table
-- action: ddl
-- lock: ACCESS EXCLUSIVE
create table "public"."notes" (
"id" bigint generated always as identity not null,
"title" text not null,
constraint "notes_pkey" primary key ("id")
);After that file is written, bunx okm migrate plan init prints no steps. The catalog already matches the schema.
-- class: expand
-- name: init
-- no stepsApply
On an empty database, apply installs the head snapshot. It does not replay the file. The history id is provisioned@ plus the migration id. An empty database.
bunx okm migrate applytarget default
applied provisioned@0001_initDrift is reported by okm check when you run it against a target, not automatically.
bunx okm checkokA column with no default
Insert one row so the table is not empty. Then add body: t.text(). t.text() is NOT NULL and has no default.
import { db } from "./db.ts";
const note = await db.notes.insert({ title: "Hello" });
console.log(note);
await db.close();{
id: "1",
title: "Hello",
}import { schema, table, t } from "okmodel/pg";
export const notes = table("notes", {
id: t.identity().primaryKey(),
title: t.text(),
body: t.text(),
});
export default schema({ tables: [notes] });bunx okm migrate plan add-body prints the statement, the lock, and the finding. It writes nothing. The command exits 1. stderr is OKM1510. okm check exits 1 with that same stderr.
bunx okm migrate plan add-body-- class: contract
-- name: add-body
-- class: contract
-- kind: add-column-required
-- action: ddl
-- lock: ACCESS EXCLUSIVE on notes, rows unknown (table not analyzed)
alter table "public"."notes" add column "body" text not null;
error OKM1525 step 1: adds NOT NULL without a default -- fix: Add the column nullable, backfill, then set it NOT NULL.error OKM1510: error OKM1525 step 1: adds NOT NULL without a default -- fix: Add the column nullable, backfill, then set it NOT NULL.
Change the statement so the finding is gone. An override must name a code that a statement in the step triggered, and the reason must not be empty.The finding is OKM1525. The fix it prints is Add the column nullable, backfill, then set it NOT NULL. This page adds the column nullable. That is the statement plan accepts. okm generate of the rejected plan still writes the file and exits 1. stderr says apply will refuse until it is fixed.
The nullable column
import { schema, table, t } from "okmodel/pg";
export const notes = table("notes", {
id: t.identity().primaryKey(),
title: t.text(),
body: t.text().nullable(),
});
export default schema({ tables: [notes] });bunx okm migrate plan add-body-- class: expand
-- name: add-body
-- class: expand
-- kind: add-column
-- action: ddl
-- lock: ACCESS EXCLUSIVE on notes, rows unknown (table not analyzed)
alter table "public"."notes" add column "body" text;The lock line is -- lock: ACCESS EXCLUSIVE on notes, rows unknown (table not analyzed).
bunx okm generate add-bodyThe command prints the path of migrations/0002_add-body.sql.
-- class: expand
-- name: add-body
-- class: expand
-- kind: add-column
-- action: ddl
-- lock: ACCESS EXCLUSIVE
alter table "public"."notes" add column "body" text;bunx okm migrate applytarget default
applied 0002_add-bodyStatus
bunx okm migrate statustarget version catalog state protected
default 0002_add-body ⟨catalog hash⟩ current falsecurrent means the target is at that version. The catalog column is the catalog hash from .okm/catalog.hash for the head schema.
| Next | You use it for |
|---|---|
| Existing database | Pull, generate, then baseline without replaying DDL. |
| Config | Targets, paths, timeouts, and roles. |
| CLI | generate, plan, apply, check, and status. |