CLI

The turbine CLI ships with the package. Use npx turbine <command> from the root of any project that has turbine-orm installed.

Command index#

npx turbine <command> [options]
 
Commands:
  init                           Initialize a Turbine project
  generate | pull                Introspect database, generate TypeScript types + client
  generate --zod                 Also emit zod.ts validation schemas
  generate --include-views       Include views + materialized views as read-only entities
  generate --no-timestamp        Omit the "Generated at:" header for byte-identical regens
  push                           Apply defineSchema() output to the database
  migrate create <name>            Create a new SQL migration file
  migrate create <name> --auto     Auto-generate migration from schema diff
  migrate create <name> --from-diff Like --auto, but flags destructive statements
  migrate create <name> --recipe <r> Scaffold a sanctioned pattern (e.g. backfill)
  migrate up                     Apply pending migrations
  migrate deploy                 Apply pending migrations without prompts (CI)
  migrate down [--step N]        Rollback the last applied migration (or last N)
  migrate status                 Show applied vs pending migrations
  seed                           Run the seed file (.ts / .js / .sql)
  status                         Show connection info + schema summary
  doctor                         Cost-aware triage of missing FK indexes
  doctor --fix                   Write a CONCURRENTLY migration that adds them
  doctor --json                  Machine-readable report (schemaVersion 1)
  doctor --unused                Report never-scanned + redundant indexes (no auto-drop)
  doctor --audit                 Unused report scoped to doctor's own suggestions
  doctor --no-plan-divergence    Skip the cached-plan divergence section
  migrate-from-prisma            Map a schema.prisma onto Turbine (report + typed name map)
  migrate-from-prisma --no-db    Parse-only: write the report without resolving names
  migrate-from-prisma --if-db    Regenerate the name map, skipping when no database resolves
  studio                         Launch local read-only Studio web UI
  mcp                            Start read-only MCP server over JSON-RPC stdio
  observe                        Launch local metrics dashboard

Global options#

OptionDescription
--url, -u <url>Postgres connection string. Overrides DATABASE_URL.
--out, -o <dir>Output directory for generated code (default: ./generated/turbine).
--schema, -s <name>Postgres schema name (default: public).
--dry-runPrint SQL without executing.
--verbose, -vDetailed logging.

turbine init#

Bootstrap a Turbine project in the current directory. init is a sequenced flow that detects what already exists and runs only the missing steps: write turbine.config.ts, scaffold a starter ./turbine/schema.ts and ./turbine/seed.ts (if a root-level ./seed.ts from an older init already exists, that one is kept instead, so a re-run never creates a second seed file), and (when a reachable database is configured) offer to push the schema, generate the typed client, and run the seed file.

npx turbine init
npx turbine init --url postgres://user:pass@localhost:5432/mydb

Re-runs are safe: existing files are detected and their steps are skipped, so you can run init repeatedly. A destructive push keeps the usual typed confirmation.

Flags#

FlagDescription
--yes, -yAccept every step's default without prompting.
--skip-schemaDon't scaffold the starter schema file.
--skip-seedDon't scaffold the seed file or offer to run it.
--skip-pushDon't offer to push the schema to the database.
--skip-generateDon't offer to generate the typed client.

In an interactive terminal, init prompts for each step. Without a TTY it degrades gracefully: it scaffolds the files and generates the client (the previous behavior), skips push and seed, and prints a note pointing at --yes and the --skip-* flags.

turbine pull / turbine generate#

Introspect the live database and emit a fully-typed client.

npx turbine pull
npx turbine pull --out ./src/db
npx turbine pull --schema inventory

Reads information_schema + pg_catalog and writes three files to the output directory:

  • types.ts, entity interfaces, Create / Update inputs, relation-included helpers
  • metadata.ts, runtime SchemaMetadata constant (column maps, relations, indexes)
  • index.ts, typed TurbineClient subclass + turbine() factory

--zod#

Emit a fourth file, zod.ts, with a Zod schema per table (XSchema, XCreateSchema, XUpdateSchema). The generated file imports the user-side zod dependency; the Turbine runtime never does.

npx turbine generate --zod

See Zod Schemas for the output shapes and type mapping.

--include-views#

Introspect views and materialized views as read-only entities alongside base tables.

npx turbine pull --include-views

Views get entity types and read accessors; write builders reject them (TURBINE_E003). See Views & Generated Columns.

--no-timestamp#

Every generated file carries a Generated at: <ISO timestamp> header, which changes on every run and shows up as noise in diffs. Pass --no-timestamp to omit it, so regenerating an unchanged schema produces byte-identical output.

npx turbine generate --no-timestamp

Useful when generated code is committed to source control or checked in CI, a generate that changes nothing leaves the tree clean.

--import-ext <mode>#

Controls the extension on the sibling imports between the generated files (types.ts, metadata.ts, index.ts).

npx turbine generate --import-ext js     # './types.js'  (NodeNext / ESM resolution)
npx turbine generate --import-ext none   # './types'     (bundler / classic resolution)
npx turbine generate --import-ext auto   # detect from tsconfig (default)

auto walks up from the output directory to the nearest tsconfig.json and picks the spelling that config's module resolution requires. Set it explicitly if your build resolves differently from what detection infers.

--legacy-to-many-uniques#

Since 0.41, a child table whose foreign-key columns are exactly covered by a unique constraint or unique index introspects as a one-to-one (hasOne) relation rather than hasMany, so the parent side is typed Child | null instead of Child[]. That is the correct shape, and it matches Prisma, but it is a breaking type change for clients generated before 0.41.

npx turbine generate --legacy-to-many-uniques

restores the pre-0.41 hasMany output while you port the affected call sites. It is also settable as legacyToManyUniques: true in turbine.config.ts, and it only affects what introspection emits, never runtime behavior.

Renaming derived relations, relationNames#

A database does not name its relationships, so introspection composes a name from the foreign key. Two foreign keys pointing at the same table produce names nobody would guess:

messages.sender_id    -> people.msgsBySender
messages.recipient_id -> people.msgsByRecipient

A codebase migrating from another ORM already has names for these, chosen by different rules, so every call site would have to be hand-edited. Declare the mapping once instead (new in v0.51):

// turbine.config.ts
export default {
  relationNames: {
    people: {
      msgsBySender: 'sentMessages',
      msgsByRecipient: 'receivedMessages',
    },
  },
};

turbine generate then emits sentMessages / receivedMessages everywhere, in the metadata and the types alike, and they work in where, with, and orderBy like any other relation.

A typo is an error, not a silent no-op: an unknown table, an unknown source relation, or a target name that would shadow a column fails the command with TURBINE_E003, listing the names that do exist. Ignoring a bad mapping would break the very call sites it was written to fix, and it would break them at runtime rather than at generate time.

Not sure what a relation ended up called? Query it wrong once. The unknown-field error lists the table's relations alongside its columns and suggests the closest match.

turbine push#

Apply your defineSchema() output directly to the database. The fast path for local development.

npx turbine push              # Apply schema changes
npx turbine push --dry-run    # Preview generated SQL without executing

push diffs turbine/schema.ts against the live database and executes the difference as a single transaction. Safe to re-run: it is a no-op when the schema already matches. For production deploys, use migrations instead.

push runs the same destructive scanner as migrate up. If the diff contains a data-destroying statement (a DROP TABLE / DROP COLUMN, a lossy ALTER COLUMNTYPE, and similar), push refuses to run, prints an itemized report, and applies nothing. To proceed, confirm interactively (type destroy my data, then yes) or pass --allow-destructive:

npx turbine push --allow-destructive   # apply data-destroying schema changes

turbine migrate create <name>#

Create a new migration file.

# Blank migration, write SQL manually
npx turbine migrate create add_users_table
 
# Auto-generate from diff between defineSchema() and live DB
npx turbine migrate create add_email_index --auto
 
# Like --auto, but flag destructive statements in the file
npx turbine migrate create sync_schema --from-diff

Writes turbine/migrations/<timestamp>_<name>.sql with -- UP and -- DOWN sections. Auto mode populates both sections from the schema diff. Blank mode gives you empty sections to fill in.

--from-diff#

Like --auto, --from-diff derives the forward statements into -- UP and the reverse statements into -- DOWN from the schema diff (an "irreversible, write manually" placeholder is written when no reverse can be derived). The difference is safety annotation: any data-destroying statement in either direction (a lossy ALTER COLUMNTYPE in UP, a DROP TABLE / DROP COLUMN reverse in DOWN) is flagged inline with loud comments and a file-level banner, and left intact so migrate up still refuses it by default unless you confirm interactively or pass --allow-destructive. Diff warnings (e.g. enum value removals the diff won't apply automatically) are surfaced as -- NOTE: comments.

--from-diff cannot be combined with --auto or --recipe.

--recipe <name>#

Scaffold a sanctioned migration pattern instead of a blank or diff-derived file. The one recipe today is backfill, which lays out the two-phase expand/contract skeleton for changing a populated column's type (nullable add, batched UPDATE backfill, SET NOT NULL, atomic rename swap) with placeholders to fill in. --recipe without a name errors.

npx turbine migrate create widen_order_total --recipe backfill

See Migrations in Practice for the full pattern.

Example output of --auto:

-- 20260409143022_add_email_index.sql
-- UP
CREATE UNIQUE INDEX "users_email_idx" ON "users" ("email");
 
-- DOWN
DROP INDEX "users_email_idx";

turbine migrate up#

Apply every pending migration in timestamp order. Each migration runs in its own transaction, and the whole command takes a pg_try_advisory_lock() so concurrent runs are safe.

npx turbine migrate up
npx turbine migrate up --dry-run

Checks for checksum mismatches before applying: if a previously-applied migration file has been edited, the command halts and reports the conflict. Pass --allow-drift to bypass that checksum validation when you are intentionally rewriting history. Use --step N to apply only the first N pending migrations instead of all of them.

Destructive-migration guard#

migrate up and migrate down scan the SQL they are about to run for statements that destroy data (DROP TABLE, DROP SCHEMA, DROP COLUMN, TRUNCATE, DELETE FROM, UPDATE without a WHERE, ALTER COLUMNTYPE). Comments and string literals are ignored, and structure-only drops (DROP INDEX, DROP CONSTRAINT, DROP TRIGGER) are not flagged.

When a destructive statement is found, Turbine refuses to run anything and prints an itemized report. To proceed you must either confirm interactively, typing the literal phrase destroy my data, then yes, or pass --allow-destructive explicitly (required in CI and other non-interactive shells). Programmatic callers of migrateUp/migrateDown opt in with allowDestructive: true.

turbine migrate deploy#

Apply pending migrations non-interactively, the command for CI/CD and production deploys. Unlike migrate up, deploy never prompts, so it works with no TTY.

npx turbine migrate deploy
npx turbine migrate deploy --dry-run   # list pending migrations without applying

deploy applies every pending migration inside the same advisory-lock + per-migration-transaction machinery as migrate up, and reports N applied. It applies files as written: the destructive-migration guard runs when you author a migration (create / up), not at deploy time, so it assumes your migration history has already been reviewed. Before it applies anything destructive, deploy prints a one-line NOTICE listing the data-destroying statements it is about to run, then proceeds (it never prompts).

It refuses to run (exit 1, clear message) on a checksum mismatch or a missing migration file: a drifted history fails the deploy instead of silently diverging. When you are intentionally deploying over a rewritten history, pass --allow-drift to bypass that check (the same escape hatch as migrate up). It never auto-generates, seeds, or pushes; it only applies pending migrations.

# Typical CI pipeline
npx turbine migrate deploy
npx turbine seed

turbine migrate down#

Roll back the most recently applied migration using its -- DOWN section.

npx turbine migrate down            # roll back the last migration
npx turbine migrate down --step 2   # roll back the last 2 migrations

Pass --step N (or -n N) to roll back the last N applied migrations instead of just one. A -- DOWN that drops a column is destructive and hits the same destructive gate as migrate up.

turbine migrate status#

Show which migrations have been applied and which are pending.

npx turbine migrate status

Example output:

i 2 applied, 1 pending
 
 Status    | Migration                          | Applied at
-----------|------------------------------------|-------------------------
 v Applied | 20260401120000_create_users.sql    | 2026-04-01 12:00:00 UTC
 v Applied | 20260402091234_add_posts.sql       | 2026-04-02 09:12:34 UTC
 . Pending | 20260409143022_add_email_index.sql |
 
  Run npx turbine migrate up to apply pending migrations.

Each row carries a status marker: v Applied, . Pending, or ! Drifted. An edited migration (its file no longer matches the checksum recorded when it was applied) shows as ! Drifted and blocks migrate up until you reconcile it. An applied migration whose file was deleted from disk shows as ! Missing file, so a drifted history is visible in status, not just at apply time.

turbine seed#

Run the configured seed file.

npx turbine seed
npx turbine seed --verbose

Turbine resolves the seed from the seedFile field in turbine.config.ts (the pre-0.50 spelling seed is still accepted as an alias), or the first default candidate found: seed.ts, seed.js, seed.sql, turbine/seed.ts, turbine/seed.js, then turbine/seed.sql. Each extension has its own runner:

  • .ts, run with npx tsx (no build step). Export a defineSeed(fn) or run inserts directly.
  • .js, imported dynamically; a default-export function is called.
  • .sql, executed as raw SQL.
// seed.ts
import { defineSeed } from 'turbine-orm';
 
export default defineSeed(async (db) => {
  await db.raw`INSERT INTO orgs (name) VALUES (${'Acme'}) ON CONFLICT DO NOTHING`;
});

See Seeding for defineSeed, typed inserts, and the CI deploy-then-seed pipeline.

turbine status#

Show the current database connection, schema summary, and generated client location.

npx turbine status

Outputs the detected database name, host, Postgres version, table count, and the path to the generated client, handy for verifying CI environments.

turbine doctor#

Cost-aware triage of missing foreign-key indexes.

npx turbine doctor
npx turbine doctor --fix
npx turbine doctor --json

Turbine loads with relations as correlated subqueries: the child table is probed once per parent row (child.fk = parent.pk). When the probed column is indexed, that is an index seek per parent. When it is not, it is a full table scan per parent, which multiplies by the parent row count and can turn a fast query into a many-second one. Batched-loader ORMs pay a missing FK index only once (a single WHERE fk IN (...) scan), so schemas migrated from them routinely lack the FK indexes Turbine's strategy needs. doctor finds them before they hit production.

It introspects the database, derives every column set relations will probe (hasMany/hasOne child FKs, belongsTo reference keys, many-to-many junction keys), and then reads live Postgres statistics to score each finding into one of three tiers.

Three-tier triage#

doctor never just lists missing indexes: an index is not free (it taxes every write, dilutes cache, and can disable heap-only-tuple updates), so it weighs the cost against the benefit and sorts findings into three buckets, each with the numbers behind the verdict:

  • Take freely: a large table with a low write rate and few existing indexes. The index is almost pure upside.
  • Take deliberately: a real write rate (writes/day normalized by the stats-reset age), many existing indexes, or a table currently relying on HOT updates. Worth a second look before you add write cost.
  • Scrutinize: a tiny table (a sequential scan is already cheap), an append-only log shape (high inserts, near-zero reads), or a never-analyzed table where the stats cannot support a verdict.

Every finding prints its size, writes/day since the last stats reset, existing index count, and probing relations, plus the exact CREATE INDEX statement. The thresholds behind the tiers are printed too, so you can see the precise number that drove a verdict and disagree with it.

Two extra signals ride the same statistics:

  • Partial indexes: when a probed FK column is mostly NULL (null fraction at or above 90%), doctor suggests a partial CREATE INDEX ... WHERE col IS NOT NULL, which covers every relation probe (the correlation is child.fk = parent.pk, and NULL never equals anything) at a fraction of the size. It prints the caveat that a hand-written where: { fk: null } filter will not use it.
  • HOT-update awareness: a table with a high HOT-update ratio and real update volume gets a warning and is bumped to at least "take deliberately", because a new index can disqualify those heap-only-tuple updates and amplify write cost.

If Postgres statistics are unavailable, or too young or absent to trust (for example the stats have never been reset), doctor says so in one line and falls back to the size-sorted topology report instead of faking confidence. On non-Postgres engines the statistics layer is skipped and you get the same topology-only output.

Invalid indexes#

doctor also reports invalid indexes (pg_index.indisvalid = false). These are the artifact of a CREATE INDEX CONCURRENTLY that failed partway: the corpse is left behind, and IF NOT EXISTS then silently skips it on a rerun so the index never actually builds. doctor surfaces each one with a DROP INDEX CONCURRENTLY statement to clear it.

--fix writes a CONCURRENTLY migration#

By default --fix writes a migration that adds the missing indexes with CREATE INDEX CONCURRENTLY IF NOT EXISTS. Because a concurrent build cannot run inside a transaction, the file carries a -- turbine:no-transaction directive in its header, and turbine migrate up runs it without wrapping it in BEGIN/COMMIT (one statement at a time). The generated file also documents the idempotency requirement, the invalid-index trap, and lock-timeout guidance.

npx turbine doctor --fix
# Created migration: 20260708143022_add_relation_fk_indexes.sql
 
npx turbine migrate up
# ! Running ...add_relation_fk_indexes.sql WITHOUT a transaction (-- turbine:no-transaction).
# v Applied 1 migration(s)

Pass --no-concurrently for a plain, in-transaction CREATE INDEX migration instead (handy for dev databases where the concurrent build is not worth the wait). Non-Postgres engines always get the plain form.

Workload-heat boost#

When a _turbine_metrics table exists (written by db.$observe() with its default Postgres sink), doctor maps per-model query heat onto physical tables and uses it as an extra benefit signal: a table your app hits hard is a table where a missing index hurts most. A hot finding is annotated (hot in your workload: N queries/min, p95 X ms) and sorted first. Heat is a benefit signal only, never a cost one, so it re-prioritizes and annotates but never downgrades a cost tier. Read metrics from a separate database with --metrics-url.

When the metrics table is absent, or observe is configured with a non-Postgres sink, doctor prints one honesty line that heat boosting is unavailable and continues, exactly as it does when statistics are missing.

doctor --unused (report-only)#

doctor also learns to subtract. --unused reports indexes that are candidates for removal, in three classes:

  • Never scanned: idx_scan = 0 (or below --min-scans N) since the last statistics reset. Every finding prints the stats-reset age and the caveats: usage counters zero on a crash or a stats reset, and a read replica's index scans never feed the primary's counters, so an index only a replica uses looks dead here. Primary-key, unique-constraint, exclusion-constraint, and replica-identity indexes are excluded from day one.
  • Redundant prefix duplicates: a non-unique index whose columns are a leading prefix of a wider index that already serves the same lookups. A unique or primary-key prefix is never called redundant (dropping it would remove a constraint).
  • Invalid indexes: the failed-CONCURRENTLY corpses, now carrying the drop suggestion.

The output is DROP INDEX CONCURRENTLY statements with the size each reclaims. This is printed only: it is never written to a migration and never auto-applied. There is deliberately no --fix for drops, because whether an index is truly unused is a judgment the counters can only inform, not settle.

npx turbine doctor --unused
npx turbine doctor --unused --min-scans 10   # treat < 10 scans as unused
npx turbine doctor --unused --json           # additive: adds unused/redundant/invalid arrays

doctor --audit#

--audit is the --unused never-scanned machinery scoped to doctor's own previously-suggested indexes (the idx_<table>_<cols> names doctor --fix emits). It frames the output as: doctor previously suggested these indexes; N have never been scanned since the stats reset (age printed); consider dropping. Because those names are truncated to Postgres's 63-byte identifier limit, the matcher compares truncated names and flags any post-truncation collision between different column sets as ambiguous rather than issuing a confident verdict.

The cached-plan divergence check#

doctor also scores every column it already knows about (relation probe columns and leading index columns) for a distribution that can flip a cached plan, and prints a separate section for it. Skip it with --no-plan-divergence; it adds one pg_stats read.

The shape it models is narrow on purpose: a read shaped WHERE col = $1 ORDER BY <other indexed column> LIMIT $n, where rows / n_distinct (what a generic plan assumes col = $1 matches) sits above the plan boundary while some real values sit far below it. For those values a promoted generic plan keeps the ordered index scan and has to walk a large fraction of the table before it accumulates one page of matches, where a custom plan takes a bitmap scan over the value's own rows. The boundary itself is sqrt(limit x relpages), which is where an ordered scan's limit / matching share of the pages equals a bitmap scan's own; measured flip points track it within 13 to 26% across two orders of magnitude of limit. Both halves of that derivation assume the matching rows are scattered through the heap, so on a clustered column there is no flip at any limit and the crossover does not describe the table. That is the same blind spot as the rest of the check: treat a crossover as a reason to run the diagnostic block, never as a measurement.

Each finding prints the statistics behind it (rows, distinct values, the generic estimate, the rarest bucket, the crossover at limit 20 and at limit 1000, correlation, and the last ANALYZE) plus how many pages the wrong plan walks and what fraction of the table that is. It deliberately does not print an amplification multiplier: the check can see how many rows a value has, not where they physically sit in the heap, and the second half moves the real cost by an order of magnitude.

The unindexed-filter mechanism (new in 0.57.0)

The rule above is one mechanism. 0.57.0 adds a second, and it is genuinely a third direction rather than a variation: it is not the sparse-value direction described above, and it is not the dense-clustering direction that was written, measured and removed in 0.56.0 for predicting the wrong sign. Here the filter column has no index at all, so the good plan is a sequential scan that the generic plan will not choose.

With nothing serving col = $1, the custom planner's alternative is a seq scan plus a top-N sort, bounded by the table's pages. A promoted generic plan cannot see that the value is rare, keeps the ordered primary-key walk, and fetches nearly every tuple before it fills the LIMIT. Before 0.57.0 those columns were dropped before the check counted them as considered, so they appeared neither in the findings nor in the "not scored" notices: they were silently outside the population.

The fixture, printed so the numbers below are checkable rather than asserted (PostgreSQL 16, warm cache, synchronize_seqscans, max_parallel_workers_per_gather and jit off):

CREATE TABLE t (id int PRIMARY KEY, organization_id int NOT NULL, payload text NOT NULL);
INSERT INTO t SELECT g, <bucket(g)>, repeat('p', 60)
  FROM generate_series(1, 20000) g ORDER BY (g * 2654435761::bigint) % 1000003;
-- 247 relpages, buckets 10,000 / 6,000 / 3,998 / 2
-- read: WHERE organization_id = $1 ORDER BY id LIMIT $2, on the rarest value, limit 20
plan_cache_modeplanbuffers
force_custom_planSeq Scan250
force_generic_planIndex Scan on the primary key20,074

Under auto, seven executions of the rare value report generic_plans 2, custom_plans 5, so the promotion is real on that fixture and not a hypothetical.

The remedy is different, so the report is too. An unindexed column's flip is fixed by adding the index, not by a plan-cache override, so when the same run's index advisor already names the column, the divergence is rendered as evidence on that missing-index finding rather than as a second entry, and it never suggests forceCustomPlan there. The leftover case (a column served only by a partial or expression index, where the planner has no path for the bare predicate) keeps its own entry. Adding the index moves the divergence in both directions at once, so re-run doctor after adding it rather than assuming the finding is closed: the column is expected to reappear on the sparse-value rule, and on the measured fixture the index also stopped auto promoting at all.

A hash index is an equality path, so a column served by one is scored by the sparse-value rule rather than called unindexed (measured: with a hash index the custom plan is a 7-buffer Bitmap Heap Scan, not a 247-page seq scan). A column served only by brin, gin or gist is reported as not scored, with the reason, because neither rule describes what its custom plan does.

Every finding ships a copy-pasteable diagnostic block, and its first step is not an EXPLAIN:

SELECT generic_plans, custom_plans FROM pg_prepared_statements WHERE name = 'turbine_divergence';

A finding describes exposure, not an incident. auto promotes only when the generic plan's estimated cost is not worse than the average custom cost, and on many of these shapes it is worse, so the backend never promotes at all and there is nothing to fix. While generic_plans is 0 you are already getting custom plans. Only when it is climbing do the two EXPLAINs below it mean anything. The block ends by resetting plan_cache_mode, synchronize_seqscans and max_parallel_workers_per_gather so a paste does not leave your session pinned.

There is no --fix. For a sparse-value finding the remedy is application code (forceCustomPlan on the affected reads, available on the core client and, since 0.57.0, through turbine-orm/prisma-compat), and the index that looks like a fix is measured not to be one: a composite index on (col, order_col) makes the good plan better without stopping the generic plan from choosing the other one. For an unindexed-filter finding the first remedy is the index itself, and the report says so instead of suggesting the per-query lever.

The findings are checked against the planner (new in 0.58.0)

Statistics can say how bad a flip would be. Only the planner can say whether it is reachable. In 0.57.0 the unindexed-filter branch reported on statistics alone, and a measured sample of 13 such findings held up only 6 times: every false positive had one signature, the generic plan keeping the same sequential scan the good plan chose, so there was nothing to diverge to.

Each unindexed-filter finding now costs one EXPLAIN without ANALYZE, which plans and discards, executes nothing, and returns in microseconds:

PREPARE p AS SELECT * FROM t WHERE col = $1 ORDER BY ord LIMIT $2;
SET LOCAL plan_cache_mode = force_generic_plan;
EXPLAIN (FORMAT JSON) EXECUTE p(NULL, 20);

Only the generic plan is needed: the finding's claim is that a promoted plan performs an ordered index walk, so a generic plan that is not that walk refutes it whatever the custom plan does. Two independent grounds count as "not that walk": a Sort above the target table's scan (which bounds the cost by how many rows match rather than by how far the walk travels, whatever access feeds it), or a Seq Scan at the target itself. A Sort elsewhere in the plan, and an Incremental Sort, deliberately do not refute. That also removes the need for a representative rare value, which statistics do not carry. --json gains planDivergenceScored.flipProbed and .flipRefuted.

A probe that fails keeps its finding. Errors, timeouts and unparseable plans leave the finding in place with a notice. A diagnostic that deletes findings when the database is uncooperative fails invisibly in exactly the environments where you are least able to check.

--json#

doctor --json emits a stable, versioned report (schemaVersion: 1) covering every finding (tier, metrics, and the create/drop SQL), the invalid indexes, the thresholds, workload-heat availability, and any degradation notices. planDivergence and planDivergenceNotices are always present as arrays (empty when the check found nothing or was skipped), and planDivergenceScored reports how large the scored population was (considered / indexed / unindexed) so a consumer can tell "considered and clean" from "never looked". Each finding carries a branch ('sparse-value' or 'unindexed-filter'), and since 0.57.0 the branch-shaped fields are optional: crossoverRows, crossoverRowsWide, valuesBelowCrossover, walkPages, walkFraction and approxAmplification are absent on an unindexed-filter finding rather than zero-filled, and tuplesWalked, worstCaseAmplification, orderColumnCorrelation and heapNearlyOrdered are present only there. Branch on branch rather than reading them unconditionally. The versioned envelope is a contract, so a consumer that parses it will not break when new fields are added. The unused, redundant, audit, and invalid arrays are ALWAYS present: they are empty when the corresponding scan did not run, and a subtraction object reports which scans ran (unusedRan, auditRan, minScans) so the key set never varies by flag. Each unused-index entry also carries a structured shape (kinds, accessMethod, definition) alongside the human-readable caveat string, so a consumer never has to parse prose.

Where this fits#

Turbine's advantage here is that it knows which columns its correlated subqueries will probe before any traffic exists, so it can advise from schema topology alone, then sharpen that with local statistics. The advice is free, runs on your machine with no telemetry, and produces reviewed migrations with DBA-grade caveats. Prisma's Optimize is client-extension telemetry sent to its cloud rather than a local advisor, and Drizzle has nothing in this space; the tools that do read live index usage (pganalyze, Supabase's index_advisor, pg_stat_statements front-ends) work from the SQL your database has already run, not from the ORM's knowledge of what it is about to run.

Dev-mode warning#

You don't have to remember to run doctor. In non-production (NODE_ENV !== 'production'), the first query that builds a relation subquery over an unindexed FK logs a one-time warning naming the relation, the table and columns, and the exact CREATE INDEX statement. The warning only fires when the schema metadata actually carries index information (introspected or generated clients), so defineSchema-only setups see no false positives.

turbine migrate-from-prisma#

Parse a schema.prisma, resolve every name in it against your live database, and emit a migration report plus a typed name map that the turbine-orm/prisma-compat adapter runs on.

DATABASE_URL=postgres://... npx turbine migrate-from-prisma --schema prisma/schema.prisma
 
# Audit a schema with no database in reach
npx turbine migrate-from-prisma --schema prisma/schema.prisma --no-db
 
# Regenerate on install, skipping quietly when no database is reachable
npx turbine migrate-from-prisma --if-db

The parser is a zero-dependency subset parser: neither prisma nor @prisma/client needs to still be installed. Outputs land in the generate output directory (default ./generated/turbine):

  • prisma-migration-report.md: per-model resolution, a many-to-many audit list pairing each Prisma field name with its Turbine relation name and junction table, junction tables detected for implicit many-to-many, enums, unresolved items, and parser notes
  • prisma-map.ts: the typed PRISMA_MAP consumed by the compat adapter
  • the standard generated client (types.ts, metadata.ts, index.ts)

Connection string#

The command resolves the database URL in this order: --url, then DATABASE_URL, then url in turbine.config.ts, then the datasource block of the schema you pointed it at, including its env("...") indirection (url first, then directUrl). A literal url = "postgres://..." is read too.

So a project whose schema says url = env("DATABASE_URL_STAGING") needs no flag, as long as that variable is exported:

export DATABASE_URL_STAGING=postgres://...
npx turbine migrate-from-prisma --schema prisma/schema.prisma

The datasource is deliberately last: an explicit --url is never overridden by a value declared in a schema file. When the run does use it, the command says so. When nothing yields a URL, the "no database URL" error gains a fourth suggestion naming the exact variable the datasource asked for.

Flags#

FlagDescription
--schema <file>Path to schema.prisma (default prisma/schema.prisma). In this command --schema names the Prisma file, not the Postgres namespace it means everywhere else; the namespace is fixed to public here.
--url, -u <url>Connection string, unless --no-db.
--out, -o <dir>Output directory (default ./generated/turbine), which must resolve inside the directory you ran the command from. See The --out directory guard.
--no-dbParse-only. Writes the report and skips introspection, so no prisma-map.ts and no client are emitted.
--allow-partialExit 0 even when items are unresolved. Without it an unresolved item exits 1.
--if-dbExit 0 without doing anything when no connection string resolves, instead of failing. Makes the command safe to run from postinstall.
--no-timestampOmit the generated-at lines for byte-identical regeneration.

Unresolved items never block the generated client: it is built from live introspected metadata, so a partial run still produces a working db. See Migrating from Prisma for the full workflow and the adapter's documented gaps.

Keeping prisma-map.ts current#

Nothing re-runs this command for you, and a prisma-map.ts that has fallen behind your schema fails silently: the adapter goes on translating the names it has, so a newly added model is simply not on the compat client. Put the command where you already regenerate, next to prisma generate:

{
  "scripts": {
    "postinstall": "prisma generate && turbine migrate-from-prisma --if-db"
  }
}

--if-db is what makes that safe. An npm ci in a build image has no DATABASE_URL, and without the flag the missing database would fail the install; with it, the run prints one line saying nothing was regenerated and exits 0.

Since 0.60 the emitted map also records a fingerprint of the schema.prisma it was generated from, and createPrismaCompatClient warns once at startup, in development only, when the file on disk no longer matches. See Keeping the map current.

The --out directory guard#

migrate-from-prisma writes a report, a name map, and a full generated client. It refuses to write any of it outside the directory you ran it from:

$ npx turbine migrate-from-prisma --out /tmp/turbine-scratch
Output directory must be within the project root. Got: /tmp/turbine-scratch
# exit code 1

Specifics worth knowing before you script it:

  • "Project root" means the process's current working directory, not the directory holding turbine.config.ts. The path is resolved against the cwd and must land on it or below it. A relative --out that climbs out (--out ../shared/generated) is refused for the same reason.
  • The refusal comes after introspection. The command has already connected and read your schema by the time it checks, so the failure costs a round trip and produces no files at all.
  • This guard is specific to migrate-from-prisma. turbine generate --out has no such restriction.

If you are generating into a scratch directory, run the command from inside it and point --schema at the absolute path instead:

mkdir -p /tmp/turbine-scratch && cd /tmp/turbine-scratch
DATABASE_URL=postgres://... npx turbine migrate-from-prisma \
  --schema /path/to/app/prisma/schema.prisma \
  --out ./generated

turbine studio#

Launch a local, read-only web UI for exploring your database. In the default mode the write endpoints are not registered in the router at all (they 404), every read runs inside BEGIN READ ONLY so writes are impossible at the database level, there is no raw-SQL input surface, and PII-tagged columns are redacted server-side before the response is serialized. As of July 2026, no other TypeScript ORM ships a studio that is read-only by default or that redacts PII.

DATABASE_URL=postgres://user:pass@localhost:5432/mydb npx turbine studio
npx turbine studio --port 5173 --no-open
# Non-loopback bind is refused unless you opt in:
# npx turbine studio --host 0.0.0.0 --allow-remote

Three tabs: a visual findMany Query builder with a live Copy-TS preview (no raw-SQL surface at all), Data browsing, and Schema inspection, all inside BEGIN READ ONLY with loopback binding and per-process token auth.

Studio has its own page covering the builder, saved queries, every flag, and the full security model: Studio.

turbine mcp#

Start a read-only Model Context Protocol server so AI agents (Claude Code, Cursor) can inspect your database safely. Speaks JSON-RPC 2.0 over stdio; exposes read-only tools only, no writes, no raw SQL.

DATABASE_URL=postgres://user:pass@localhost:5432/mydb npx turbine mcp

Six tools: schema_overview, table_detail, migrate_status, doctor_report, explain_query (schema-validated findMany-style args only, no free-form SQL), and sample_rows (≤ 50 rows). Every database access runs inside BEGIN READ ONLY. --include / --exclude scope which tables are exposed.

Full setup, tool reference, and Claude Code / Cursor config: MCP Server.

turbine observe#

Launch the local metrics dashboard over the _turbine_metrics table written by db.$observe().

TURBINE_OBSERVE_URL=postgres://... npx turbine observe

See Observability for the event API, the metrics engine, and the dashboard.

Config resolution#

Turbine looks for configuration in this order, stopping at the first match:

  1. CLI flags (--url, --out, --schema)
  2. Environment variables (DATABASE_URL)
  3. turbine.config.ts, turbine.config.mts, turbine.config.js, or turbine.config.mjs in the project root, in that order, stopping at the first that exists
  4. Built-in defaults

Example turbine.config.ts:

import type { TurbineCliConfig } from 'turbine-orm/cli';
 
const config: TurbineCliConfig = {
  url: process.env.DATABASE_URL,
  out: './generated/turbine',
  schema: 'public',
  migrationsDir: './turbine/migrations',
  seedFile: './turbine/seed.ts',
  schemaFile: './turbine/schema.ts',
};
 
export default config;

See also#