Schema
Turbine gets its schema one of two ways: you declare tables in TypeScript with defineSchema(), or it introspects an existing database. Either way, turbine generate writes the same typed client, so you can mix the two.
Define tables with defineSchema#
// turbine/schema.ts
import { defineSchema } from 'turbine-orm';
export default defineSchema({
organizations: {
id: { type: 'serial', primaryKey: true },
name: { type: 'text', notNull: true },
},
users: {
id: { type: 'serial', primaryKey: true },
email: { type: 'text', unique: true, notNull: true },
orgId: { type: 'integer', notNull: true, references: 'organizations.id' },
role: { type: 'text', notNull: true, default: "'member'" },
createdAt: { type: 'timestamptz', notNull: true, default: 'now()' },
},
});Table and field keys are camelCase; Turbine writes snake_case names to the database (orgId becomes org_id). Relations are not declared: they come from foreign keys. Apply the schema with npx turbine push in development, or with migrations.
Column types#
type | Postgres | TypeScript |
|---|---|---|
serial, bigserial | SERIAL, BIGSERIAL | number |
smallint, integer, bigint | SMALLINT, INTEGER, BIGINT | number |
real, double | REAL, DOUBLE PRECISION | number |
numeric | NUMERIC | string |
text, varchar, uuid | TEXT, VARCHAR(maxLength), UUID | string |
boolean | BOOLEAN | boolean |
timestamp, timestamptz | TIMESTAMPTZ (both) | Date |
date | DATE | Date |
json, jsonb | JSONB (both) | unknown, or tsType |
bytea | BYTEA | Buffer |
enum | the type named by enumName | union of the labels |
vector | vector(dimensions) (pgvector) | number[] |
There is no code-first spelling for a zone-less TIMESTAMP column. Create one in a SQL migration and generate picks it up. A bigint or bigserial value above Number.MAX_SAFE_INTEGER reads as a string.
Column options#
| Option | Type | What it does |
|---|---|---|
type | see above | Required. |
primaryKey | boolean | PRIMARY KEY. |
notNull | boolean | NOT NULL. Columns are nullable otherwise. |
nullable | boolean | Documents a nullable column; emits nothing. |
unique | boolean | UNIQUE. |
default | string | number | boolean | A string is SQL text ("'member'", 'now()'); a number or boolean is a literal. |
references | string | object | Foreign key, 'table.column' or { target, onDelete?, onUpdate? }. |
maxLength | number | Length of a varchar. |
enumName | string | Required for type: 'enum'. |
dimensions | number | Required for type: 'vector'. |
array | boolean | Makes the column an array (TEXT[]), typed T[]. |
check | string | Column CHECK expression, as SQL text. |
pii | boolean | Hides the column from default reads. See PII fields. |
updatedAt | boolean | Stamps the column on every update. See updatedAt. |
tsType | string | TypeScript type for the generated client. See tsType. |
Table options#
| Option | Type | What it does |
|---|---|---|
primaryKey | string[] | Composite primary key, by field name. |
indexes | { columns, unique?, name? }[] | Declared indexes. The default name is idx_<table>_<columns>. |
checks | { name?, expression }[] | Table CHECK constraints. |
manyToMany | { name, target, through, sourceKey, targetKey }[] | Declares a many-to-many relation for schemaDefToMetadata. Introspection finds junction tables on its own. |
memberships: {
userId: { type: 'integer', notNull: true, references: 'users.id' },
orgId: { type: 'integer', notNull: true, references: 'organizations.id' },
role: { type: 'text', notNull: true },
primaryKey: ['userId', 'orgId'],
indexes: [{ columns: ['orgId', 'role'] }],
checks: [{ name: 'valid_role', expression: "role IN ('member', 'admin')" }],
},findUnique takes a composite key as an object: where: { userId: 1, orgId: 2 }. Every foreign-key column gets an index automatically. A declared index with the same name replaces it.
Foreign keys#
posts: {
id: { type: 'serial', primaryKey: true },
userId: {
type: 'integer',
notNull: true,
references: { target: 'users.id', onDelete: 'cascade' },
},
},
// "user_id" INTEGER NOT NULL REFERENCES "users"("id") ON DELETE CASCADEonDelete and onUpdate take 'cascade', 'restrict', 'set null', 'set default' or 'no action'.
A reference to a table in the same schema must name a column that exists, or defineSchema throws ValidationError (E003). A table outside the schema is not checked.
Enums#
export default defineSchema(
{
posts: {
id: { type: 'serial', primaryKey: true },
status: { type: 'enum', enumName: 'post_status', notNull: true, default: "'draft'" },
},
},
{ enums: { post_status: ['draft', 'published', 'archived'] } },
);On PostgreSQL this emits CREATE TYPE "post_status" AS ENUM (...) before the tables that use it. MySQL gets an inline ENUM(...), and SQLite and SQL Server get a text column with a CHECK over the labels.
Unknown keys throw#
A key defineSchema does not know throws ValidationError (E003) with the nearest valid key, and is a compile error in an object literal:
role: { type: 'text', defaultValue: "'member'" },
// defineSchema: unknown key "defaultValue" in column "users.role". Did you mean "default"?The same error covers an index naming an unknown column, a table or column named __proto__, and two fields that map to one database column.
Column tags#
pii, updatedAt and tsType change how the client behaves, not the table. The database has nowhere to store them, so turbine generate reads them from your schema file.
PII fields#
users: {
id: { type: 'serial', primaryKey: true },
email: { type: 'text', notNull: true, pii: true },
},A PII column is left out of every default read, including nested with rows and the row a write returns.
To read it, pass includePii: UNSAFE, a privilege option. Naming the column in select without it throws ValidationError (E003).
- Writes accept PII values as usual.
where,orderByandhavingon a PII column are always allowed.groupByby a PII column, and_min/_maxover one, needincludePii: UNSAFE._count,_sumand_avgdo not.- A PII column that is part of the primary key is always returned. Tag sensitive data, not keys.
- Studio and the MCP server redact PII cells, using the tags in your generated metadata.
The generated entity type marks each PII field optional.
Auto-updated timestamps, updatedAt#
posts: {
id: { type: 'serial', primaryKey: true },
title: { type: 'text', notNull: true },
updatedAt: { type: 'timestamptz', notNull: true, default: 'now()', updatedAt: true },
},Every update, updateMany and the update half of an upsert sets the column to the current time, unless the payload names it. It is Prisma's @updatedAt. Inserts are not stamped, so give the column a default.
Typing a JSON column, tsType#
settings: { type: 'jsonb', tsType: '{ theme: string; beta?: boolean }' },A json or jsonb column generates as unknown. tsType replaces that in the generated types, adding | null for a nullable column.
The text must be one type on one line. It can reference your own types with import('../src/types.js').Settings.
tsType changes only the generated TypeScript, not how values are read or written. The same override can live in turbine.config.ts as columnTypes, which wins when both name a column.
How generate picks up the tags#
turbine generate introspects the database, then overlays pii, updatedAt, maxLength and tsType from your schema file (schemaFile in turbine.config.ts, default turbine/schema.ts). It prints a line such as Applied column tags from turbine/schema.ts: 2 pii, 1 updatedAt.
If the schema file cannot be loaded, or a pii or updatedAt tag names a table or column the database lacks, generate fails rather than emit an unprotected client. A project with no file at the default path skips the overlay.
Introspection#
npx turbine generate # alias: turbine pullgenerate reads tables, columns, keys, indexes, enums and STORED generated columns, and derives relations from foreign keys (hasMany, belongsTo, hasOne, and many-to-many through junction tables). Pass --include-views for views. It writes three files to ./generated/turbine/:
types.ts: entity interfaces plus*Createand*Updateinput types.metadata.ts: the runtimeSCHEMAmetadata.index.ts: the typedTurbineClientsubclass and theturbine()factory.
When a derived relation name would collide with a column, the relation gets a different name. To choose names yourself, use relationNames.
Runtime metadata without a database, schemaDefToMetadata#
schemaDefToMetadata() builds the client metadata from a defineSchema result, with no connection and no codegen. Use it for tests, in-memory SQLite and PowDB:
import { defineSchema, schemaDefToMetadata } from 'turbine-orm';
import { turbinePowDB } from 'turbine-orm/powdb';
const schema = defineSchema({ /* ... */ });
const db = await turbinePowDB({ embedded: './data' }, schemaDefToMetadata(schema));Apply schema changes#
| Command | Use it for |
|---|---|
npx turbine push --dry-run | Print the SQL that would bring the database in line with the schema file. |
npx turbine push | Apply it. Development only. |
npx turbine migrate create <name> --auto | Write that SQL into a reviewable migration. See Migrations. |
The diff creates tables and adds or changes columns, indexes and constraints.
A column missing from the schema produces a DROP COLUMN, which push and migrate up refuse until you confirm. A table missing from the schema is listed but never dropped.
The same steps are available from code:
import { schemaDiff, schemaPush, schemaToSQL } from 'turbine-orm';
import schema from './turbine/schema.js';
const ddl = schemaToSQL(schema); // string[], no connection
const diff = await schemaDiff(schema, process.env.DATABASE_URL!);
console.log(diff.statements, diff.warnings);
await schemaPush(schema, process.env.DATABASE_URL!); // one transaction| Function | Notes |
|---|---|
schemaToSQL(schema, options?) | Full DDL as string[] (schemaToSQLString joins it). Options: dialect for MySQL, SQLite or SQL Server DDL, and extensions: 'manual' to skip CREATE EXTENSION vector. |
schemaDiff(schema, url, { schema? }) | PostgreSQL only. Returns { create, alter, drop, statements, reverseStatements, warnings }. |
schemaPush(schema, url, options?) | PostgreSQL only. Options: dryRun, allowDestructive, precomputedDiff, schema. A destructive statement throws DestructivePushRefusal, a ValidationError listing the statements on .destructive. |
Type mapping#
How generate types introspected Postgres columns:
| Postgres | TypeScript |
|---|---|
int2, int4, float4, float8 | number |
int8 | number, or string above Number.MAX_SAFE_INTEGER. Writes also take a string or bigint. |
numeric, money | string, to keep precision. Writes also take a number. |
text, varchar, uuid, citext | string |
boolean | boolean |
timestamptz | Date |
timestamp, date | Date, read as UTC (details) |
time, timetz | string on read, string | Date on write (details) |
interval | { years?, months?, days?, hours?, minutes?, seconds?, milliseconds? } |
point, circle | { x, y }, { x, y, radius } |
ranges, bit, macaddr8 and other geometric types | string |
json, jsonb | unknown |
bytea | Buffer |
| arrays | T[]. bigint[] reads as string[]. |
Zone-less columns: timestamp and date read as UTC#
A timestamp (without time zone) or date value has no offset, so Turbine reads it as UTC rather than in the process's local zone. A stored date '2026-07-21' reads as 2026-07-21T00:00:00.000Z wherever the process runs.
Writes match: a Date bound to a zone-less column is written with its UTC components. SQLite, MySQL and SQL Server bind a Date for a date column as its UTC day.
To read and write in local time instead, pass utcTimestamps: false. The setting is process-wide:
- The first client in the process settles it. A later client asking for the opposite throws
ValidationError(E003). - A client that opens its own pool installs the parsers on the
pgmodule, so other code usingpgin the process reads UTC too. - A client on a pool you supply applies the parsers to its own queries only, but takes part in the same check.
infinity and -infinity#
PostgreSQL stores infinity and -infinity in date, timestamp and timestamptz columns. No Date can hold them, so Turbine reads them as the numbers Infinity and -Infinity on every read path.
row.validUntil.toISOString()throws aTypeErroron those rows. Checktypeof value === 'number'first.JSON.stringifyrenders them asnull.where: { validUntil: null }does not match them. Usewhere: { validUntil: 'infinity' }.- The first read of a stored infinity logs a one-time warning (code
temporal-infinity), even in production. SettingtemporalInfinityexplicitly silences it.
Writing the number back stores infinity again, so a read-modify-write round-trips.
Time-of-day columns: time and timetz#
A time or timetz column reads as the driver's string, '09:00:00'. Writes also take a Date, narrowed to its UTC time of day (Prisma's convention for @db.Time):
await db.shifts.create({ data: { startsAt: '09:00:00' } });
await db.shifts.create({ data: { startsAt: new Date('1970-01-01T09:00:00Z') } });The same narrowing applies in where and in a groupBy having on the column. In an aggregate filter such as having: { startsAt: { _min: { gte } } }, pass a string instead: a Date there fails with 22007.
See also#
- Migrations: the production migration workflow.
- CLI:
generate,pushand every flag. - Reading and writing: querying the tables you defined.