Skip to content

The migration workflow

chkit turns schema changes into SQL ahead of time and commits that SQL to git, rather than computing the difference live against the database at deploy. This page describes that architecture and the workflow it produces: what snapshot.json holds, which files belong in version control, and how a team ships changes when only some developers can reach production.

A migration is the difference between the schema you have and the schema you want. That difference can be computed at two moments.

  • At deploy, against the live database. The tool connects, introspects the current schema, and works out the changes on the fly. This is how Doctrine works.
  • Ahead of time, on a developer’s machine, then stored. The difference is computed once, written to a file, reviewed, and committed. Deploy replays it. This is how chkit works, like Prisma Migrate or Drizzle.

chkit uses the second approach. chkit generate computes the diff and writes it to a SQL file; chkit migrate later replays that exact SQL. The migration is a reviewable artifact that travels with the pull request, and deployment is deterministic because it never regenerates anything. Because the diff is worked out ahead of time, computing it needs no database connection at all. (For the full split of which commands connect and which run offline, see the CLI overview.)

The chkit migration workflow: edit schema.ts, run generate offline to write migration SQL and snapshot.json, commit both to git, then migrate applies the same files per environment in CI, each tracking its own _chkit_migrations journal.

The diff needs a baseline: that’s the snapshot

Section titled “The diff needs a baseline: that’s the snapshot”

To compute where the schema is against where it should be, the diff needs both sides. The target side is your TypeScript schema. The baseline, the last-known state, has to come from somewhere.

There are two ways to supply that baseline. Introspect a live database, which ties every diff to a reachable instance and to which instance you point at. Or read a committed file. chkit keeps the baseline in a file: chkit/meta/snapshot.json.

This is what snapshot.json is for. generate diffs your schema against the snapshot, writes the migration SQL, then rewrites the snapshot to the new state so the next diff has a fresh baseline. Because the baseline is a committed file rather than a live introspection, generation is deterministic: the same schema and snapshot always produce the same migration.

generate writes two files, and the record of which migrations have run lives somewhere else entirely. That split is the whole story of what belongs in version control.

generate produces two files, both under chkit/ by default. They are the source of truth for a migration, so both belong in version control.

FileWhat it isCommit?
chkit/migrations/<timestamp>_<name>.sqlThe SQL to apply, with a header describing each operation and its risk level. Without --name it is named <timestamp>_auto.sql.Yes
chkit/meta/snapshot.jsonThe last-known schema state. generate diffs against it, then overwrites it with the new state.Yes

The default locations come from outDir (./chkit); migrationsDir and metaDir override the two subfolders individually. See Configuration.

Both files belong in git. migrate decides what is pending by comparing the .sql files on disk against what it has already applied, and it verifies a checksum of every applied file that is present. These checks validate the artifacts that are committed — they do not catch one you forgot to commit: a missing migration is simply absent from the pending set and never runs, and the checksum verification skips an applied file whose .sql is gone rather than failing. Keeping both files in the repo is a discipline to enforce in code review, not something the tooling detects for you.

The record of which migrations have run is deliberately not a file. It lives in a _chkit_migrations table inside each ClickHouse database. That is what lets test, stage, and prod each sit at a different point while sharing the same committed files: every environment tracks its own applied set, so there is nothing about applied state to commit. See chkit migrate.

This is the workflow for a team where only some developers can reach production. Assume one person has a one-time connection to prod and nobody else does.

  1. Seed the project once. The person with access runs chkit pull against prod to introspect the existing schema into TypeScript, then chkit generate to produce the initial snapshot. Commit the schema files, the migration(s), and snapshot.json.

    Terminal window
    chkit pull --out-file ./src/db/schema/pulled.ts # connects to prod, one time
    chkit generate --name baseline # offline; writes snapshot.json
  2. Everyone else works offline. Any developer edits the TypeScript schema and runs generate. No production access and no local ClickHouse are needed, because the snapshot is the baseline being diffed against.

    Terminal window
    # edit src/db/schema/*.ts
    chkit generate --name add_level_column # offline

    They review the generated .sql and commit it with the updated snapshot.json. The pull request now contains the exact SQL that will run.

  3. CI deploys. chkit migrate --apply runs in the pipeline against each environment. This is the only step that connects, and it applies the reviewed, committed SQL. See CI/CD integration.

Nothing in this loop asks a developer to generate against a live ClickHouse. Doing so would tie a migration’s correctness to that instance matching prod; diffing against the committed snapshot removes that dependency.

The same committed migration files flow through every environment: test, then stage, then prod. Each has its own _chkit_migrations journal, so migrate applies only what an environment is missing and skips what it already has. Promotion is running chkit migrate --apply against the next environment with the same commit checked out.

A migration does not have to come from generate. Because deploy replays committed .sql files, migrate applies any .sql file in the migrations directory that is not yet in the journal, whether chkit wrote it or not. There is no scaffold command for a blank migration, and none is required.

To add custom SQL such as a data seed or a backfill, create a file in chkit/migrations/ following the <timestamp>_<name>.sql naming convention so it orders correctly alongside generated ones:

-- chkit/migrations/20260707120000_seed_lookup_data.sql
INSERT INTO default.regions (id, name) VALUES
(1, 'emea'),
(2, 'amer'),
(3, 'apac');

Run chkit migrate and it is picked up like any generated migration, applied in timestamp order and recorded in the journal. A few things carry over from generated migrations:

  • It is immutable once applied. The checksum gate blocks edits afterward, the same as for generated files.
  • Destructive statements (DROP TABLE, DROP COLUMN, TRUNCATE, …) require --allow-destructive in CI, even when hand-written. See destructive operation safety.
  • Large data loads, such as a multi-minute INSERT ... SELECT or a backfill, can be marked mode=async so chkit submits the query without blocking on the HTTP response and polls for progress. See async operations.

For structural changes that must preserve data, the common pattern is a hand-written migration that creates a new table, runs INSERT INTO new SELECT ... FROM old, then swaps names. See the DSL reference.