Write Kysely queries and migrations following project conventions. Use when writing migrations, creating tables, adding columns, creating enums, writing database queries with Kysely, or using database transactions.
Write Kysely queries and migrations following project conventions. Use when writing migrations, creating tables, adding columns, creating enums, writing database queries with Kysely, or using database transactions.
Kysely
Follow these conventions when writing Kysely queries and migrations in this project. The project uses CamelCasePlugin, which converts all identifiers from camelCase to snake_case automatically.
INSERT is the only write
The application schema is 100% append-only (load the database-design skill for the full doctrine). Application code never calls updateTable, deleteFrom, or truncate on application tables, and never uses .onConflict((oc) => oc.doUpdateSet(...)) — an upsert's update arm is an UPDATE. State changes are new event rows; current state is derived at query time (distinctOn latest-wins, EXISTS checks — see the database-design skill for the canonical derivation patterns).
.onConflict((oc) => oc.doNothing()) is fine: it makes inserts idempotent without mutating anything.
Prefer Kysely builder over raw SQL
Use the schema builder for operations Kysely supports natively. Reserve sql template literals for things the builder cannot express.
Instead of:
await sql`CREATE TYPE plot_summary_status AS ENUM ('pending', 'completed', 'failed')`.execute(db)
Standard PostgreSQL types that work as string literals: 'text', 'integer', 'boolean', 'uuid', 'timestamp', 'timestamptz', 'bytea', 'jsonb', 'json'.
When raw SQL is appropriate
Use sql template literals for:
PostgreSQL extensions: await sqlCREATE EXTENSION IF NOT EXISTS pgcrypto.execute(db)
Function calls in defaults: col.defaultTo(sqlgen_random_uuid()), col.defaultTo(sqlnow())
Custom type references in addColumn: sqlplot_summary_status`` (see CamelCasePlugin section below)
PostgreSQL functions in queries: sqlsha256(bytes), sqlencode(bytes, 'hex')
Complex expressions: CTEs with raw subqueries, function composition
CamelCasePlugin awareness
The CamelCasePlugin transforms all identifiers in Kysely builder calls from camelCase to snake_case. Raw SQL via sql template literals bypasses the plugin entirely.
This means:
Builder methods use camelCase: createType('plotSummaryStatus') produces CREATE TYPE plot_summary_status
Raw SQL uses snake_case: sqlplot_summary_status`` stays as-is
This matters most when referencing custom types in addColumn — the type argument goes through sql, so it must be snake_case:
await db.schema
.createType('plotSummaryStatus') // camelCase: plugin converts to snake_case
.asEnum(['pending', 'completed'])
.execute()
await db.schema
.createTable('plotSummaries')
.addColumn('status', sql`plot_summary_status`, (col) =>// snake_case: raw SQL, no conversion
col.notNull().defaultTo('pending'),
)
.execute()
Two further plugin behaviors that bite:
JOIN aliases are rewritten too. When a query builder joins a table under a camelCase alias, raw sql fragments in the same query must reference that alias in snake_case, or the reference silently fails to resolve.
jsonb values are re-keyed recursively. Selecting a jsonb column camelCases the keys inside the stored value, not just column names. To read or assert on the real stored keys, select the column cast to text (sql(payload -> 'context')::text``) and JSON.parse it yourself.
And one Postgres quirk in raw inserts: a bound array literal does not parse into a custom-enum-array column — cast it with ::text[] in the sql template instead of the enum's own array type.
Parameterized fragments and person names
A parameterized sql fragment invoked in both a select and its orderBy binds its parameter twice, and under .distinct() Postgres rejects the query (ORDER BY expressions must appear in select list) because the two placeholder sets never compare equal. Order by the output alias instead: .select([fragment(arg).as('name')]).orderBy('name').
Queries that render a person's name on any company- or organization-scoped surface use the fragments from app/business/display-names.server.ts — organizationDisplayNameSql(organizationId), organizationNamePartSql(organizationId, column), organizationInitialsSql(organizationId) — with the org id from context, never a hand-rolled join on userProfileRevisions. Display names are per-organization (the employment's name, falling back to the global profile, then email); a global-profile join shows one organization's name for a person inside another organization. globalDisplayNameSql belongs only on genuinely global surfaces: the user's own profile and staff-* admin views.
Deterministic ordering for display
When a query orders user-visible rows by a non-unique column (a date, a status, a quantity), add a tiebreak on a stable human-meaningful column (lotNumber, code, a name) before any final orderBy('id'). Primary keys are random UUIDs, so an id-only tiebreak renders tied rows in a different order on every database — which reads as arbitrary to users and breaks anything that snapshots the rendered output, like the docs screenshot DOM signatures. Rows tie more often than seed data suggests: real usage produces same-day dates constantly.
When the ordered rows come from a union of different kinds — a picker listing catalog products alongside recipes — rank the kind ahead of any id (.orderBy(sql`case when item.kind = 'product' then 0 else 1 end`)), and leave the human-meaningful tiebreak to separate twins within a kind. A name collides across kinds far more readily than within one, so a kind-blind tiebreak lets a product and a recipe of the same name trade places from one database to the next.
Always await .execute()
Every Kysely operation that calls .execute() must be awaited. Missing await creates race conditions where subsequent operations may run before the current one finishes.
Instead of:
sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`.execute(db)
await db.schema.createTable('users') // may run before extension is created
Do:
await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`.execute(db)
await db.schema.createTable('users')
camelCase in migrations
Use camelCase for all identifiers in Kysely builder calls — table names, column names, type names, constraint names, index names. The CamelCasePlugin converts them to snake_case in the generated SQL. Only use snake_case when writing raw SQL strings.
Migration files must only import from kysely and Node.js built-ins. Never import from ~/business/ or any other application code. If a migration needs application logic (e.g., for a data backfill), duplicate that logic inside the migration file. See the database-design skill for the full rationale.
Proving migrations and regenerating types
Never hand-merge app/db/types.d.ts. After any rebase or conflict, take either side, then regenerate it from a freshly migrated database (pnpm run db:migrate) and diff: expect byte-identical or a clean additions-only result. When two open PRs both carry migrations, the one merging second must re-rebase and regenerate after the first lands — git happily auto-merges a semantically wrong types file.
db:rollback reverts the most-recently-EXECUTED migration on that database, not the highest-timestamped file. After a rebase, migrations can have run out of filename order, so a rollback may hit someone else's migration. To prove a specific migration's down(), use a throwaway database where you control exactly what has run.
Prove down() against dirtied data, not only a pristine round-trip. Run the feature (or its tests) so the database holds data only the new schema can represent, then roll back. If the old schema genuinely cannot hold that data, down() must fail with a descriptive pre-check error, never a raw constraint violation.
Postgres silently truncates identifiers past 63 bytes, and the CamelCasePlugin expands compiled index and constraint names — dozens already exceed 63 bytes across main's migrations. That is harmless here: the truncation is deterministic and collision-free, every down() drops whole tables rather than any index by name, and no code references a compiled index name. The check that matters is that names stay unique after truncation to 63 bytes; never churn a clear, descriptive name merely to fit under the limit.
Minimize database roundtrips
Compose operations into a single query instead of mixing JS runtime code with multiple database roundtrips. Use returning clauses, subqueries, and CTEs to keep logic in SQL.
Append instead of check-then-branch
The mutable-schema instinct is "check whether a row exists, then insert or update". In an append-only schema there is nothing to branch on: every action appends its event row, and the latest event wins at read time.
Instead of:
const existing = awaitdb().selectFrom('invitations').where('email', '=', email).executeTakeFirst()
if (existing) {
// mutate the existing row's role
} else {
// insert a new row
}
Do:
awaitdb()
.insertInto('invitations')
.values({ email, role })
.executeTakeFirstOrThrow()
The invitation's current role is derived from the latest invitation event for that email. When an insert must be idempotent (webhooks, retried jobs), add a unique constraint on the natural key and .onConflict((oc) => oc.doNothing()).
Use .returning() instead of separate SELECT after write
Instead of:
awaitdb().insertInto('plotSummaries').values({ originalFilename }).execute()
const record = awaitdb().selectFrom('plotSummaries').where('originalFilename', '=', originalFilename).executeTakeFirstOrThrow()
When multiple queries must succeed or fail together, wrap them in a transaction. The callback receives a trx object — use it instead of db() for all queries inside:
const intake = awaitdb()
.transaction()
.execute(async (trx) => {
const record = await trx
.insertInto('records')
.values({ name: 'example' })
.returning('id')
.executeTakeFirstOrThrow()
returnawait trx
.insertInto('intakes')
.values({ recordId: record.id })
.returning('id')
.executeTakeFirstOrThrow()
})
// intake is available here after the transaction commits
Transactions auto-rollback on exceptions. The return value of the callback becomes the return value of .execute(), making it easy to pass data out after commit.
To share transaction-aware logic across functions, accept trx: Transaction<DB> as a parameter:
Use case() builder for conditional computed columns
Use Kysely's expression builder case() for SQL CASE expressions instead of deriving values in JS after the query. This keeps logic in SQL and avoids extra .map() post-processing.
eb.and([...]) / eb.or([...]) for compound conditions
eb.ref('columnName') for column-to-column comparisons (right-hand side)
$castTo<Type>() to narrow the result type (e.g., a union of string literals)
Mix eb() (camelCase, goes through CamelCasePlugin) with sql template literals (snake_case) for PostgreSQL functions
Type-annotate sql template literals used as eb() operands
When a sql template literal is used as the right-hand operand of an eb() comparison, it must have a type annotation matching the column's type. Without it, TypeScript infers RawBuilder<unknown> which is not assignable to the expected operand type.