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 matter

Mixins 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.ts reading this table has WHERE tenantId = ctx.subject.tenantId AND-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() → sets deletedAt = now() instead of actually deleting.

Restore with .restore():

ctx.store.update('notes')
  .where('id', noteId)
  .restore()                 // sets deletedAt back to NULL

To 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/clear deactivatedAt with 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 with as) plus an HNSW index over it (skip with index: false, pick the metric with distance).
  • Mutation hook — re-embeds on every insert/update of the source column. The runtime injects @voltro/ai's embed into 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()) exposes tenantId but not the transitively-required audit() columns — apply audit() 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 / updatedAt only (no actor tracking) — declare timestamp().default('now') and timestamp().default('now').onUpdate('now') directly, or use audit() if you want the *By columns too.
  • Slug from a titleslug: text().computed((row) => slugify(String(row.title))). See Computed columns.
  • Optimistic locking — add an integer version column and guard updates with the runtime's .expectVersion(n) (throws OptimisticLockError on 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.