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
  push                     Apply defineSchema() output to the database
  migrate create <name>    Create a new SQL migration file (--auto | --from-diff | --recipe)
  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                   Missing-FK-index triage + cached-plan divergence
                           (--fix, --json, --unused, --audit, --no-plan-divergence)
  migrate-from-prisma      Map a schema.prisma onto Turbine (report + typed name map)
  studio                   Launch local read-only Studio web UI
  mcp                      Start read-only MCP server over JSON-RPC stdio
  observe                  Launch local metrics dashboard
  skill                    Install the agent query skill

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 detects what already exists and runs only the missing steps: write turbine.config.ts, scaffold a starter ./turbine/schema.ts and ./turbine/seed.ts, and, when a reachable database is configured, offer to push the schema, generate the typed client, and run the seed file. A root-level ./seed.ts from an older init is kept, never duplicated.

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

Re-runs are safe: existing files are detected and their steps skipped. A destructive push keeps the usual typed confirmation. init --schema <name> probes that Postgres schema and writes it into the generated turbine.config.ts as schema: '<name>', so the next push and generate target the same namespace.

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 a terminal, init prompts for each step. Without a TTY it scaffolds the files, generates the client, 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.

--keep-column-names#

Keep the raw database column names as the generated field names (created_at stays created_at) instead of camelCasing them. The config key is keepColumnNames: true.

npx turbine generate --keep-column-names

It is also the fix for a field collision. Two columns of one table that camelCase to the same field, a quoted "createdAt" beside a created_at say, used to generate a types.ts with a duplicate member and a columnMap that kept whichever column came last, so a write to the field reached only one of them. Introspection now refuses that table with TURBINE_E003, naming every colliding column; with keepColumnNames: true the field is the raw column name and two distinct columns cannot collide. There is no per-column rename option, so the alternative is a rename in the database.

--no-timestamp#

Omit the Generated at: <ISO timestamp> header so regenerating an unchanged schema produces byte-identical output. Useful when generated code is committed or checked in CI.

npx turbine generate --no-timestamp

--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 its module resolution requires. Set it explicitly if your build resolves differently.

--legacy-to-many-uniques#

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: the parent side is typed Child | null instead of Child[]. Clients generated before 0.41 got hasMany for that shape; this flag restores it while you port the affected call sites.

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

Also settable as legacyToManyUniques: true in turbine.config.ts. It only affects what introspection emits.

Renaming derived relations, relationNames#

Introspection composes relation names from foreign keys, and two foreign keys pointing at the same table produce names nobody would guess:

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

Declare the names you want once, instead of hand-editing every call site:

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

turbine generate then emits sentMessages / receivedMessages 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.

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 (DROP TABLE / DROP COLUMN, a lossy ALTER COLUMN … TYPE, 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 -- UP and -- DOWN from the schema diff (an "irreversible, write manually" placeholder when no reverse can be derived). The difference is safety annotation: any data-destroying statement in either direction is flagged inline and in a file-level banner, and left intact so migrate up still refuses it by default. Diff warnings (e.g. enum value removals the diff won't apply automatically) surface as -- NOTE: comments.

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

--recipe <name>#

Scaffold a sanctioned migration pattern. The one recipe today is backfill, 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 check when you are intentionally rewriting history. Use --step N to apply only the first N pending migrations.

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 COLUMN … TYPE). Comments and string literals are ignored; structure-only drops (DROP INDEX, DROP CONSTRAINT, DROP TRIGGER) are not flagged.

On a hit, Turbine refuses to run anything and prints an itemized report. To proceed, confirm interactively (type the literal phrase destroy my data, then yes) or pass --allow-destructive (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 uses the same advisory-lock and per-migration-transaction machinery as migrate up. It applies files as written: the destructive guard runs when you author a migration, not at deploy time. Before applying anything destructive, it prints a one-line NOTICE listing the statements, then proceeds.

It refuses to run (exit 1) on a checksum mismatch or a missing migration file, so a drifted history fails the deploy instead of silently diverging; pass --allow-drift to deploy over an intentionally rewritten history. It never auto-generates, seeds, or pushes.

# 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. 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.

An edited migration (its file no longer matches the checksum recorded at apply time) shows as ! Drifted and blocks migrate up until you reconcile it. An applied migration whose file was deleted shows as ! Missing file.

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 (seed is an accepted 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.

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). An unindexed probe column therefore costs a full table scan per parent. Schemas migrated from batched-loader ORMs routinely lack these indexes. doctor introspects the database, derives every column set relations will probe (hasMany/hasOne child FKs, belongsTo reference keys, many-to-many junction keys), and reads live Postgres statistics to score each finding.

doctor reads session-scoped statistics, so it refuses a transaction-pooler endpoint (a pooler/pgbouncer hostname or a known pooling port; exit 1). Point it at the direct endpoint, or pass --allow-pooler to override.

Three-tier triage#

An index is not free: it taxes every write, dilutes cache, and can disable heap-only-tuple updates. doctor weighs that cost against the benefit and sorts findings into three tiers:

  • Take freely: a large table with a low write rate and few existing indexes.
  • Take deliberately: a real write rate, many existing indexes, or a table currently relying on HOT updates.
  • Scrutinize: a tiny table, an append-only log shape, 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 and the thresholds behind the tiers.

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 (NULL never equals anything) at a fraction of the size, with 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 is bumped to at least "take deliberately", because a new index can disqualify heap-only-tuple updates and amplify write cost.

If statistics are unavailable or too young to trust, doctor says so in one line and falls back to the size-sorted topology report. Non-Postgres engines get the same topology-only output.

Invalid indexes#

doctor also reports invalid indexes (pg_index.indisvalid = false), the leftovers of a CREATE INDEX CONCURRENTLY that failed partway: IF NOT EXISTS silently skips the corpse on a rerun, so the index never actually builds. Each one is surfaced with a DROP INDEX CONCURRENTLY statement to clear it.

--fix writes a CONCURRENTLY migration#

--fix writes a migration that adds the missing indexes. Each one is a DROP INDEX CONCURRENTLY IF EXISTS followed by a plain CREATE INDEX CONCURRENTLY, deliberately without IF NOT EXISTS: a concurrent build that fails partway leaves an INVALID index under the requested name, and IF NOT EXISTS matches on the name rather than on validity, so a rerun would skip the corpse and record the migration as applied while the index doctor reported is still missing. The DROP is a no-op on the first run (the index does not exist yet, which is why it was proposed) and clears the corpse on a rerun, so rerunning converges on a valid index. A concurrent build cannot run inside a transaction, so the file carries a -- turbine:no-transaction directive and turbine migrate up runs it without BEGIN/COMMIT, one statement at a time. The file's own comment documents the idempotency requirement, the invalid-index trap, and lock-timeout guidance. The CREATE INDEX IF NOT EXISTS line printed in the report itself is a different thing: advice meant for pasting into psql, where there is no rerun and no corpse to skip.

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. 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 as an extra benefit signal. A hot finding is annotated (hot in your workload: N queries/min, p95 X ms) and sorted first; heat never downgrades a cost tier. Read metrics from a separate database with --metrics-url. When the table is absent, doctor prints one line and continues.

doctor --unused (report-only)#

--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. Counters zero on a crash or a stats reset, and a replica's index scans never feed the primary's counters, so an index only a replica uses looks dead here; each finding prints the stats-reset age. Primary-key, unique-constraint, exclusion-constraint, and replica-identity indexes are excluded.
  • 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, with the drop suggestion.

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

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 never-scanned machinery scoped to doctor's own previously-suggested indexes (the idx_<table>_<cols> names --fix emits): doctor suggested these; N have never been scanned since the stats reset; consider dropping. Those names truncate at Postgres's 63-byte identifier limit, so the matcher compares truncated names and flags any post-truncation collision between different column sets as ambiguous rather than issuing a verdict.

The cached-plan divergence check#

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

The modeled shape is narrow on purpose: a read shaped WHERE col = $1 ORDER BY <other indexed column> LIMIT $n, on a backend that has promoted the prepared statement to a value-blind generic plan. Findings come in two branches:

  • sparse-value: an index serves the equality, but the generic estimate rows / n_distinct sits above the plan boundary sqrt(limit x relpages) while some real values sit far below it. For those values the generic plan keeps the ordered index scan and walks a large fraction of the table; a custom plan takes a bitmap scan over the value's own rows.
  • unindexed-filter: no index serves the equality, so the good plan is a sequential scan plus a top-N sort, and the generic planner will not choose it. 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.

Measured on a 20,000-row, 247-page fixture (PostgreSQL 16, warm cache, value buckets 10,000 / 6,000 / 3,998 / 2, querying the rarest value with limit 20):

plan_cache_modeplanbuffers
force_custom_planSeq Scan250
force_generic_planIndex Scan on the primary key20,074

Each finding prints its statistics (rows, distinct values, generic estimate, rarest bucket, crossovers at limit 20 and limit 1000, correlation, last ANALYZE) and how many pages the wrong plan walks.

The cost of a flip depends on how closely the heap tracks the ordering column: on otherwise-identical fixtures it ranged from 1.2x (heap in exact id order, the normal state of an append-mostly serial table) to 80x (hash order). The cases sit one sampled fifth decimal of pg_stats.correlation apart, so nothing is filtered on that statistic; every finding instead carries the ordering column's correlation and a heapNearlyOrdered boolean.

A hash index is an equality path, so a column served by one is scored by the sparse-value rule rather than called unindexed. A column served only by brin, gin or gist is reported as not scored, with the reason.

The unindexed-filter probe

Statistics say how bad a flip would be; only the planner can say whether it is reachable. Each unindexed-filter finding therefore costs one EXPLAIN without ANALYZE, which plans and discards, executing nothing:

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);

The finding's claim is that the promoted plan performs an ordered index walk, so a generic plan that is not that walk refutes it. Two grounds count: a Sort above the target table's scan, or a Seq Scan at the target itself. An Incremental Sort, or a Sort elsewhere in the plan, does not refute. A probe that errors, times out, or returns an unparseable plan keeps its finding, with a notice, so an uncooperative database cannot silently delete findings. --json gains planDivergenceScored.flipProbed and .flipRefuted. The check asks the planner because a closed-form gate was tried and measured wrong: the plan choice is a cost comparison that moves with the table.

Remedies and the diagnostic block

There is no --fix, because the remedy differs by branch:

  • A sparse-value finding is fixed in application code: forceCustomPlan on the affected reads, on the core client and through turbine-orm/prisma-compat. A composite index on (col, order_col) is measured not to fix it: it makes the good plan better without stopping the generic plan from choosing the other one.
  • An unindexed-filter finding is fixed by adding the index. When the same run's index advisor already names the column, the divergence renders as evidence on that missing-index finding rather than as a second entry. Re-run doctor after adding the index: the column is expected to reappear under the sparse-value rule.

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: on many of these shapes the backend never promotes to the generic plan at all, and while generic_plans is 0 you are already getting custom plans. 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.

--json#

doctor --json emits a stable, versioned report (schemaVersion: 1): every finding with tier, metrics, and create/drop SQL, plus invalid indexes, thresholds, workload-heat availability, and degradation notices. New fields are added without breaking the envelope.

  • planDivergence and planDivergenceNotices are always present as arrays (empty when the check found nothing or was skipped). planDivergenceScored reports the scored population (considered / indexed / unindexed).
  • Each divergence finding carries a branch ('sparse-value' or 'unindexed-filter'), and branch-shaped fields are optional rather than zero-filled: crossoverRows, crossoverRowsWide, valuesBelowCrossover, walkPages, walkFraction and approxAmplification are absent on an unindexed-filter finding; tuplesWalked, worstCaseAmplification, orderColumnCorrelation and heapNearlyOrdered are present only there. Branch on branch.
  • The unused, redundant, audit, and invalid arrays are always present (empty when the 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 carries a structured shape (kinds, accessMethod, definition) alongside the caveat string.

Dev-mode warning#

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. It only fires when the schema metadata 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 (Prisma field name, Turbine relation name, junction table), detected implicit-m2m junction tables, 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) or a literal url = "postgres://...". So a schema that says url = env("DATABASE_URL_STAGING") needs no flag as long as that variable is exported. The datasource is deliberately last, so an explicit --url is never overridden by a schema file; when nothing yields a URL, the error names 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. Full workflow and the adapter's documented gaps: Migrating from Prisma.

Keeping prisma-map.ts current#

A stale prisma-map.ts 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:

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

--if-db is what makes that safe: an npm ci in a build image with no DATABASE_URL prints one line and exits 0 instead of failing the install.

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

"Project root" means the process's current working directory, not the directory holding turbine.config.ts; a relative --out that climbs out (--out ../shared/generated) is refused too. The guard is specific to migrate-from-prisma; turbine generate --out has no such restriction.

To generate into a scratch directory, run the command from inside it and point --schema at the absolute path of the Prisma file.

turbine studio#

Launch a local, read-only web UI for exploring your database: a visual findMany Query builder with a live Copy-TS preview, Data browsing, and Schema inspection. By default the write endpoints are not registered (they 404), every read runs inside BEGIN READ ONLY, there is no raw-SQL input surface, and PII-tagged columns are redacted server-side.

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

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

Eleven tools: schema_overview, table_detail, relation_graph, find_join_path (returns the with clause to write), migrate_status, doctor_report, explain_query (schema-validated findMany-style args only, no free-form SQL), compile_query (the exact SQL a read query would send, without running it), table_stats, sample_rows (≤ 50 rows), and explain_error (needs no database, so it answers when the pool is unreachable). 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 skill#

Install the query-writing skill an agent should read before writing Turbine queries.

npx turbine skill                  # -> .claude/skills/turbine-orm/SKILL.md
npx turbine skill --print          # to stdout, pipe it anywhere
npx turbine skill --agents         # a short block for AGENTS.md / CLAUDE.md
npx turbine skill --dir .agent/skills

The skill ships inside the package rather than being generated, and every factual claim in it is executed against a live database before release, so it cannot drift from the ORM. Contents and the rest of the agent setup: Turbine for AI agents.

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#