Concepts

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.

schema.ts
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.

find.ts
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 });
}
find.out
[
  {
    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.

okmodel.config.ts
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:

.okm/types.d.ts
/**
 * 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:

0001_init.sql
-- 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:

apply.out
target default
applied provisioned@0001_init

The 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:

check.out
ok

After 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:

check-drift.err
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.

StepWhat 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 applyRuns pending migrations on the target.
okm checkReports 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 youPage
Types for the client, and for rows, inserts, and updatesTypes
Queries on each tableQueries
Migration SQL from one catalog to the nextDrift
The live okm check comparisonDrift
Check constraintsChecks

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

PageYou use it for
CatalogWhat schema().catalog means for generate, plan, check, and the client.
The schemaThe declaration and the tables list.
First schemaOne table, then insert and read.
First migrationGenerate the first SQL file and apply it.