Mixins
Reusable column bundles with runtime behaviour — tenant(), audit(), softDelete(), deactivation(), and how to write your own.
A mixin is a value you chain onto a table(...) declaration with .with(...). It brings more than just columns — the framework's mixins register hooks with the runtime (tenant scoping, audit row writes, soft-delete filtering) that fire transparently. Each ships from its own plugin package.
import { table, id, text } from '@voltro/database'
import { softDelete } from '@voltro/plugin-soft-delete'
import { audit } from '@voltro/plugin-audit'
import { tenant } from '@voltro/plugin-multitenancy'
export const notes = table('notes', {
id: id(),
title: text(),
body: text(),
})
.with(tenant(), audit(), softDelete()) // chain them — order doesn't matterMixins compose: when one requires another, the dep is auto-applied (deduped by stable id). softDelete(), tenant(), and deactivation() all transitively require audit(), so you don't have to list audit() separately.
Built-in mixins
tenant() — @voltro/plugin-multitenancy
.with(tenant())Adds:
- Column
tenantId: text NOT NULL(indexed). - Subscription behaviour — every
*.query.tsreading this table hasWHERE tenantId = ctx.subject.tenantIdAND-merged into the executor's own filters. - No automatic write enforcement. Mutations must call
assertOwnTenant(input.tenantId, ctx.subject)explicitly. See Multi-tenancy.
audit() — @voltro/plugin-audit
.with(audit())Adds the four audit columns + auto-fill hooks:
| Column | Type | Filled by |
|---|---|---|
createdAt |
timestamptz |
server now() on insert |
updatedAt |
timestamptz |
server now() on insert + update |
createdBy |
text (nullable) |
ctx.subject.id on insert |
updatedBy |
text (nullable) |
ctx.subject.id on update |
Use this any time you need a "who touched this row and when". For high-frequency tables (events, metrics) audit overhead is a few bytes per row — fine.
softDelete() — @voltro/plugin-soft-delete
.with(softDelete())Adds:
- Column
deletedAt: timestamptz NULL. - Read filter — every subscription auto-merges
deletedAt IS NULL. - Helper
ctx.store.delete('notes').where(...).soft()→ setsdeletedAt = now()instead of actually deleting.
Restore with .restore():
ctx.store.update('notes')
.where('id', noteId)
.restore() // sets deletedAt back to NULLTo query INCLUDING soft-deleted rows (admin audit log, recycle bin), use .withDeleted():
ctx.store.select('notes').withDeleted().where('id', noteId).maybeOne()deactivation() — @voltro/plugin-deactivation
.with(deactivation())Adds:
- Column
deactivatedAt: timestamptz NULL. - Column
deactivatedBy: text(nullable) →actors.
A row whose deactivatedAt is set is a deactivated subject (typically a user): locked out, but still visible — their name on past audit rows, assignments, and history stays intact. That's the difference from softDelete():
softDelete()→ row is hidden from default reads (recycle-bin semantics).deactivation()→ row stays visible, just flagged inactive. No read-scoping, no delete interception — you set/cleardeactivatedAtwith a normal update.
The two are orthogonal and compose: a user can be deactivated (visible) and later soft-deleted (hidden) for GDPR. deactivation() transitively requires audit() — a deactivation is an audit-worthy event, so audit() captures the WHO/WHEN via updatedBy/updatedAt alongside.
const users = table('users', { id: id(), email: text() })
.with(audit(), deactivation())
// deactivate — a plain update; the row stays visible
await ctx.store.update('users').where('id', userId).set({ deactivatedAt: new Date() })
// reactivate
await ctx.store.update('users').where('id', userId).set({ deactivatedAt: null })vectorEmbedding({ from, model, dimensions }) — @voltro/database
For RAG use cases — derives an embedding from a text column automatically.
const docs = table('docs', {
id: id(),
body: text(),
}).with(vectorEmbedding({ from: 'body', model: 'text-embedding-3-small', dimensions: 1536 }))Adds:
- Column
embedding: vector(1536)(override the name withas) plus an HNSW index over it (skip withindex: false, pick the metric withdistance). - Mutation hook — re-embeds on every insert/update of the source column. The runtime injects
@voltro/ai'sembedinto the hook. - Query helper
nearestNeighbours(query: string, k: number)that embeds the query string + finds the nearest rows.
ANN acceleration is first-class on postgres + MariaDB 11.7+; on the other dialects the column + hook still work, but search falls back to a sequential scan. See Vector columns for the deep dive.
Composing mixins
Chain every mixin in one .with(...) call (or several — they accumulate):
const notes = table('notes', {
id: id(),
body: text(),
})
.with(tenant(), audit(), softDelete())Order doesn't matter — the dep resolver materialises each mixin's columns once, and a requires graph (e.g. softDelete() → audit()) auto-applies the dependency. If two mixins declare the same column name the table fails to compile with a clear error.
A directly-applied mixin's columns (
tenantId,createdAt/updatedAt,deletedAt) are addressable in an.index([...])/.unique([...])key — declare the index/unique after.with(...)so the columns are in scope. Only directly-applied mixins count:.with(tenant())exposestenantIdbut not the transitively-requiredaudit()columns — applyaudit()explicitly if you need to index those. See Indexing mixin-contributed columns.
Patterns that aren't mixins
A few common needs are NOT shipped as mixins — they're one-liners on the column or table:
createdAt/updatedAtonly (no actor tracking) — declaretimestamp().default('now')andtimestamp().default('now').onUpdate('now')directly, or useaudit()if you want the*Bycolumns too.- Slug from a title —
slug: text().computed((row) => slugify(String(row.title))). See Computed columns. - Optimistic locking — add an
integerversioncolumn and guard updates with the runtime's.expectVersion(n)(throwsOptimisticLockErroron a stale version). See Transactions.
Writing your own mixin
A mixin is a function returning a MixinDefinition — build it with defineMixin. Columns go in fields; runtime behaviour goes in behaviors:
import { defineMixin, timestamp, type MixinDefinition } from '@voltro/database'
export const heartbeat = (): MixinDefinition<Record<string, never>> =>
defineMixin({
id: 'acme/heartbeat',
fields: {
lastSeenAt: timestamp().nullable(),
},
behaviors: {
// Stamp lastSeenAt on the row whenever it's written.
beforeUpdate: (input) => ({ ...input, lastSeenAt: new Date() }),
},
})behaviors channels:
beforeInsert(input, ctx)→ input — mutate / decorate the about-to-be-inserted row.beforeUpdate(input, ctx)→ input — same for updates.onDelete(ctx)— intercept deletes (e.g. soft-delete semantics).defaultWhere(ctx)→ predicate — AND-merged into every read, including reactive subscriptions.
A mixin can also declare requires: [otherMixin()] (resolved depth-first) and indexes: [{ fields: [...] }]. The framework's tenant() is implemented as a single-file mixin with defaultWhere doing the AND-merge — read the source for a full reference.
When NOT to use a mixin
- One-off columns specific to a single table — just declare them inline.
- Cross-table denormalisation — that's an event-driven workflow, not a mixin.
- "Magic" timestamp formats — keep it boring:
timestamp().default('now')is clearer than a custom mixin.