How it works
One schema produces the types, the queries, the migrations, the drift check, and the check constraints.
You write each table once, in a schema() call. The client types, the SQL each query sends, the migration files, and the check that the live database still matches all come from that one object, so they cannot disagree.
A new reader opens Installation first, then First schema.
The problem
When a table is described in three places, the copies drift apart. The TypeScript type names one column, a query sends another, and a migration creates a third, and nothing in the project notices until a request fails.
One table through the flow
This section follows one notes table from the schema to a database that has changed by hand. Every output below comes from running these files.
import { schema, table, t } from "okmodel/pg";
export const notes = table("notes", {
id: t.identity().primaryKey(),
title: t.text(),
});
export default schema({ tables: [notes] });schema() compiles the tables into a catalog: one document that names every table, column, and constraint. Everything below reads that catalog.
The client
connect takes the schema and returns db, with one typed property per table.
import { connect } from "okmodel/pg/postgresjs";
import schema from "./schema.ts";
export async function sample(url: string) {
await using db = connect(url, { schema });
return await db.notes.find({ where: { id: "1" }, limit: 1 });
}[
{
id: "1",
title: "Hello",
}
]The types
okm is the command-line tool that ships with the package. It reads okmodel.config.ts, which names the schema file and the database. That database is the default target: the database an okm command runs against when you do not pass --target.
import { defineConfig } from "okmodel/migrate";
export default defineConfig({
schema: "./schema.ts",
database: process.env.DATABASE_URL!,
});bunx okm generate init writes the first migration and, beside it, .okm/types.d.ts with the row type, the insert type, and the update type of each table. The id is generated by the database, so the insert type has no id, and every update field is optional:
/**
* Row types emitted from the schema.
* Mode: emitted.
*/
export interface Notes {
readonly id: string;
readonly title: string;
}
export interface NotesInsert {
readonly title: string;
}
export interface NotesUpdate {
readonly title?: string;
}The client reads the same row shapes from the schema value you pass to connect, so an app does not need to import this file. Types explains the types setting and the file.
The migration
okm generate init compares the catalog with an empty snapshot (a saved copy of the catalog from the last migration) and writes the SQL that turns one into the other:
-- 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")
);bunx okm migrate apply runs it on the default target:
target default
applied provisioned@0001_initThe check
Drift is a difference between the live database and the schema, such as a column someone added by hand. bunx okm check compares the two and prints ok when they match:
okAfter alter table notes add column body text is run by hand, the same command exits with status 1 and prints the drift as OKM1520, with the statement that would remove it:
error OKM1520: The database differs from the schema.
alter table "public"."notes" drop column "body"
Change the schema or the database so okm check reports no difference.Fix it in the schema, by adding the column and generating a migration, or in the database, by dropping the column. okm check never changes the database.
| Step | What runs |
|---|---|
schema() | Builds the catalog and the table types. |
connect(url, { schema }) | Opens db, typed from that schema. |
db.notes.find({ … }) | Builds the SQL when you call it and returns rows. |
okm generate <name> | Writes migration SQL from the last snapshot to this catalog. |
okm migrate apply | Runs pending migrations on the target. |
okm check | Reports drift between the target and the schema. |
How the pieces split
The schema is the model. connect is the runtime: it opens db in your app and needs only the schema and a URL. defineConfig is the tooling: it tells okm where the schema is, which targets exist, and which roles to create. The app does not import the config.
| The schema gives you | Page |
|---|---|
| Types for the client, and for rows, inserts, and updates | Types |
| Queries on each table | Queries |
| Migration SQL from one catalog to the next | Drift |
The live okm check comparison | Drift |
| Check constraints | Checks |
okm check compares a reachable database to the schema (check). Replaying the migration files into a scratch schema is a different command, okm migrate check (Lint). There is no separate okm lint command: lint findings appear in plan, apply, and those two check commands.
Limits and trade-offs
The catalog hash, a SHA-256 of the catalog document, does not depend on table order. The emitted interfaces do. Swapping two tables leaves the hash the same and changes the order of those interfaces. The schema explains why.
Roles and grants are not part of schema(). When the config names roles, the tooling adds them before a plan or a check. connect hashes the schema catalog without them.
Where to read the detail
| Page | You use it for |
|---|---|
| Catalog | What schema().catalog means for generate, plan, check, and the client. |
| The schema | The declaration and the tables list. |
| First schema | One table, then insert and read. |
| First migration | Generate the first SQL file and apply it. |