Cascade + FK auto-index
onDelete defaults to 'restrict' for safety. FK auto-index defaults to true. Real migration scenarios + when to override.
reference() columns are foreign-key declarations. The framework picks two opinionated defaults that bite users from other frameworks:
export const posts = table('posts', {
id: id({ prefix: 'post' }),
authorId: reference(() => users, {
onDelete: 'cascade', // delete author → delete posts. Default 'restrict'.
onUpdate: 'noAction', // FK PK never changes in practice. Default 'noAction'.
index: true, // B-tree on authorId. Default true.
}),
title: text(),
})onDelete defaults to 'restrict'
The framework is opinionated: deleting a user shouldn't silently nuke 100k posts. 'restrict' is the default to surface FK violations LOUDLY at delete time.
| Value | Meaning |
|---|---|
'restrict' (default) |
Refuse the delete if child rows exist. Parent delete throws. |
'cascade' |
Delete child rows along with the parent. Use for owned lifecycles. |
'setNull' |
Set the child's FK to NULL (column must be .nullable()). |
'noAction' |
DB-level NO ACTION (postgres / mysql) — effectively same as restrict but defers the constraint check to commit. |
Pick based on the lifecycle relationship between parent and child:
'cascade'for genuinely-owned children. Junction tables (memberships when a user OR org is deleted), child entities that have no meaning without the parent (acomment_reactionsrow when the comment is deleted).'setNull'when the child outlives the parent in a degraded form. Aposts.editorIdwhen the editor user leaves the team — keep the post, drop the editor pointer.'restrict'(default) when you want the framework to FORCE you to clean up explicitly. Most parent-child relationships in a typical app.
index: true default
Every reference() column gets a B-tree index automatically. The framework's index audit confirms it at boot.
reference(() => users) // → CREATE INDEX posts_author_id_idx ON posts (author_id);
reference(() => users, { index: false }) // → no indexThe lookup cost of a missing FK index dwarfs the write cost of an unnecessary one for nearly every workload. Opt out only for tiny lookup tables where a full-scan beats index maintenance — lookup_codes (a 20-row enum-like table), singleton_config (one row total). Anywhere a JOIN would happen, keep the default.
Real migration scenarios
Scenario 1 — cascade through a junction
export const orgMemberships = table('org_memberships', {
userId: reference(() => users, { onDelete: 'cascade' }),
orgId: reference(() => orgs, { onDelete: 'cascade' }),
role: text(),
})Delete a user → all their memberships cascade away. Delete an org → all its memberships cascade away. Standard pattern; the junction has no meaning without both parents.
Scenario 2 — setNull on optional ownership
export const posts = table('posts', {
authorId: reference(() => users, { onDelete: 'cascade' }), // owner — cascade
editorId: reference(() => users, { onDelete: 'setNull' }).nullable(), // optional → setNull
})If the editor leaves the team, posts stay but lose the editor pointer. The author's deletion still removes the post.
Scenario 3 — restrict forcing explicit cleanup
export const invoices = table('invoices', {
customerId: reference(() => customers, { onDelete: 'restrict' }), // default
})
// Application code:
async function deleteCustomer(customerId: string) {
const openInvoices = await store.query(
database.invoices.where(eq('customerId', customerId)).descriptor,
)
if (openInvoices.length > 0) {
throw new CustomerHasOpenInvoicesError({ count: openInvoices.length })
}
await ctx.store.delete('customers', customerId)
}The 'restrict' default forces the application to think about what "delete customer" means. Auto-cascading invoices would corrupt accounting; auto-setNull would orphan them. The explicit check + typed error is the right contract.
Per-dialect emission
| Dialect | FK clause shape |
|---|---|
| postgres | REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION |
| mysql | REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION |
| mariadb | REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION |
| mssql | REFERENCES [users]([id]) ON DELETE CASCADE (mssql has no NO ACTION distinct from default) |
| sqlite | REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION |
SQLite requires PRAGMA foreign_keys = ON to enforce — the framework sets this on every connect. Without it, sqlite is permissive (treats FK constraints as documentation only).
onUpdate — usually 'noAction'
FK referenced columns are IDs. IDs don't change in practice — that's the whole point of having an ID. So onUpdate defaults to 'noAction' and you usually don't touch it.
If you DO need to change an ID across a hierarchy (e.g. consolidating two organizations into one — orgId: 'a' → orgId: 'b'), the right answer is usually to:
- Find every row that references the old ID.
- Update them all in a single transaction.
- Delete the now-orphaned old parent row.
Not to set onUpdate: 'cascade' and trigger a single huge update through the DB engine. The cascade approach locks every affected table for the duration and breaks any subscriptions watching those tables.
Composite FKs — not supported
The framework's reference() is single-column only. If your schema has a composite FK (multi-column referential integrity), you'll have to:
- Declare each column individually with the right type.
- Add the FK constraint via
unsafe()SQL in a migration. - Skip the framework's relation declaration for that link.
This is rare. Composite FKs usually indicate a schema design that could collapse into a surrogate-key approach.
Index audit warnings
The framework's boot-time index audit warns about redundant indexes — a FK auto-index that's a leading prefix of an explicit composite index is redundant:
[voltro:dev] WARN index audit · redundant-prefix
table: memberships
redundant: memberships_user_id_idx (from reference auto-index)
coveredBy: memberships_user_org_idx (explicit composite on (user_id, org_id))
hint: drop oneDecide based on workload. If you frequently query by user_id alone, keep both. If the composite is the only access pattern, drop the auto-index via { index: false }.
Caveats
- Cascade can chain deeply. A delete on
userscascades tomemberships, then to anything that referencesmembershipswith cascade, and so on. Trace the depth before you set'cascade'on a high-cardinality table. - Postgres
DEFERRABLE INITIALLY DEFERREDFKs. The framework doesn't emit DEFERRED constraints. If you need them (mass-loading scenarios), emit them in a custom migration. - MariaDB enforces FK constraints in storage engines that support them. InnoDB (the default) enforces; MyISAM doesn't. Use InnoDB. The framework's DDL emitter assumes it.
- SQLite enforcement requires
PRAGMA foreign_keys = ON. The framework sets this at connect. Other clients that open the same database without setting the pragma bypass FK enforcement silently.
Where it lives
voltro/packages/database/src/columns.ts—reference()acceptsonDelete/onUpdate/indexvoltro/packages/database/src/migrate.ts—referentialAction(action, dialect); theREFERENCES <table> (id)clause is emitted inline in the column-DDL builder alongside itvoltro/packages/database/src/indexAudit.ts— boot-time redundant-prefix warnings