API · Data (advanced)
A library/catalog api that tours the advanced schema DSL — the entity/relations split with eager loading, full-text search, dbEnum, array + generated + encrypted columns, and declarative query caching.
A small library/catalog (authors + books) that closes the advanced schema-DSL gaps — the features that are documented but shown in no other api template: the *.entity.ts / *.relations.ts split with eager .with() loading, full-text search, dbEnum, array / generated / encrypted columns, and declarative query result caching. Reach for it as a worked reference when you're modelling something richer than a single flat table. Template id: api-data-advanced.
Scaffold
voltro create-project acme --api=api-data-advancedWhat ships
apps/acme/api/ # dir named by the app, not the template
├── app.config.ts # type:api, store:'memory', governancePlugin({ fieldEncryption: true })
├── .env # DEV-ONLY VOLTRO_FIELD_ENCRYPTION_KEY (see below)
├── package.json
├── tsconfig.json
├── README.md
├── database/
│ ├── actors.entity.ts # core audit-subject table
│ ├── tenants.entity.ts # core tenant boundary
│ ├── authors.entity.ts # name + .encrypted() bio
│ ├── books.entity.ts # dbEnum genre, array tags, generated slug, FTS index
│ ├── authors.relations.ts # author → many books
│ ├── books.relations.ts # book → one author
│ └── index.ts # databaseHandle({...}) + relation registration
├── queries/
│ ├── books.search.query.ts(.server) # FTS via .matching(...) + cache
│ └── authors.withBooks.query.ts(.server) # eager-load via .with({ books: true })
└── seeds/
└── catalog.seed.ts # demo authors + booksstore: 'memory' keeps boot zero-infra (no Docker, data resets on restart). The schema DSL is identical across every SQL backend — switch app.config.ts to store: 'postgres' to see the native DDL the migrator emits (CREATE TYPE … ENUM, text[], the STORED generated column, the tsvector + GIN FTS index). On memory the same features run through the in-process store: FTS degrades to a substring scan, arrays round-trip as JS arrays, encryption still applies. See the SQL dialects guide.
The *.entity.ts / *.relations.ts split
Tables are declared one per file with the *.entity.ts extension. Relations are declared OUTSIDE the table descriptor, in a sibling *.relations.ts file — the framework registers them at boot and the query builder's .with() chain uses them to eager-load.
// database/authors.entity.ts
import { id, table, text } from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy/mixin'
export const authors = table('authors', {
id: id({ prefix: 'author' }),
name: text(),
// Encrypted at rest. Requires governancePlugin({ fieldEncryption: true }).
bio: text().encrypted().nullable(),
})
// tenant() pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
.with(tenant())
.reactive()// database/authors.relations.ts — declared OUTSIDE the table descriptor
import { relations } from '@voltro/database'
import { authors } from './authors.entity'
import { books } from './books.entity'
// An author has MANY books. `foreignKey` auto-derives because exactly one
// reference() column on `books` (`authorId`) points back at `authors`.
export const authorsRelations = relations(authors, ({ many }) => ({
books: many(books),
}))// database/books.relations.ts — the inverse, a book belongs to ONE author
import { relations } from '@voltro/database'
import { authors } from './authors.entity'
import { books } from './books.entity'
export const booksRelations = relations(books, ({ one }) => ({
author: one(authors),
}))The handle barrel imports the *.relations.ts files (side-effecting) so the eager-load walker can resolve them:
// database/index.ts
import { databaseHandle, type InferRow } from '@voltro/database'
import { actors } from './actors.entity'
import { tenants } from './tenants.entity'
import { authors } from './authors.entity'
import { books } from './books.entity'
// Register relations (side-effecting imports).
import './authors.relations'
import './books.relations'
export { actors, tenants, authors, books }
export type Author = InferRow<typeof authors>
export type Book = InferRow<typeof books>
export const database = databaseHandle({ actors, tenants, authors, books })See Relations.
Eager loading — .with({ books: true })
authors.withBooks returns each author with their books attached as an array, in ONE SQL roundtrip (the dialect's JSON-aggregation idiom — postgres jsonb_agg, sqlite json_group_array, mysql JSON_ARRAYAGG, mssql FOR JSON PATH). The per-branch orderBy/limit apply PER author.
// queries/authors.withBooks.query.server.ts — executor (server-only)
import type { AppContext } from '@voltro/runtime'
import { database } from '../database/index'
const execute = (_input: Record<string, never>, _ctx: AppContext) =>
database.authors.with({
books: { orderBy: [{ column: 'title', direction: 'asc' }], limit: 50 },
})
export default executeThe descriptor's output carries the nested books array, and source: ['authors', 'books'] makes the reactive subscription re-run when EITHER table changes — a new book under an author pushes a fresh snapshot:
// queries/authors.withBooks.query.ts — descriptor (browser-safe)
import { defineQuery } from '@voltro/protocol'
import { Schema } from 'effect'
const Book = Schema.Struct({
id: Schema.String, authorId: Schema.String, title: Schema.String,
summary: Schema.String, genre: Schema.String,
tags: Schema.Array(Schema.String), slug: Schema.String, tenantId: Schema.String,
})
export const authorsWithBooks = defineQuery({
name: 'authors.withBooks',
source: ['authors', 'books'],
input: Schema.Struct({}),
output: Schema.Struct({
id: Schema.String, name: Schema.String,
// `bio` is the .encrypted() column — handlers + the wire see plaintext.
bio: Schema.NullOr(Schema.String), tenantId: Schema.String,
books: Schema.Array(Book), // eager-loaded relation
}),
})See Eager loading.
Full-text search — .fullTextIndex + .matching
books declares one FTS index over title + summary. That single declaration compiles to a different backend per dialect (postgres tsvector + GIN, mysql/mariadb FULLTEXT, sqlite FTS5, mssql ranked-LIKE fallback). The books table also carries the other specialized columns — dbEnum, array(text()), and a stored .generatedAs(...):
// database/books.entity.ts
import { array, dbEnum, id, reference, table, text } from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy/mixin'
import { authors } from './authors.entity'
// Native ENUM type — declared module-level as a reusable handle. `as const`
// is required for the literal-union type. postgres → CREATE TYPE … ENUM
// (ADD VALUE is O(1)); mysql/mariadb → native ENUM(...); mssql/sqlite → CHECK.
export const bookGenre = dbEnum('book_genre', [
'fiction', 'nonfiction', 'fantasy', 'sciFi', 'mystery', 'biography',
] as const)
export const books = table('books', {
id: id({ prefix: 'book' }),
authorId: reference(() => authors), // FK → authors, auto-indexed
title: text(),
summary: text(),
genre: bookGenre.column().default('fiction'), // native ENUM column
tags: array(text()).default([]), // text[] on postgres; JSON codec elsewhere
// DB-computed STORED column derived from `title` on INSERT + UPDATE.
// `stored: true` persists it on disk so it's indexable. Same-row columns
// only. Raw SQL emitted verbatim — keep it portable (quoted identifier).
slug: text().generatedAs(`lower("title")`, { stored: true }),
})
.fullTextIndex('bookSearch', ['title', 'summary'], {
config: 'english',
weights: { title: 'A', summary: 'B' },
})
.with(tenant())
.reactive()The query executor compiles the FTS predicate via .matching('bookSearch', q) — resolving the index's covered columns + config from the declaration on the table. No tenant filter is added by hand; books carries tenant(), so the runtime AND-merges eq('tenantId', subject.tenantId):
// queries/books.search.query.server.ts — executor (server-only)
import type { AppContext } from '@voltro/runtime'
import { database } from '../database/index'
const execute = (input: { q: string }, _ctx: AppContext) =>
database.books.matching('bookSearch', input.q).limit(50)
export default executeSee Full-text search, Enums, Arrays & intervals, and Generated columns.
Field encryption — .encrypted() + the DEV-ONLY .env
authors.bio is flagged .encrypted(). The store middleware transparently encrypts it on write (AES-256-GCM) and decrypts on read — handlers always see plaintext while the column stores an opaque enc:v1:… string on disk on every dialect. An .encrypted() column is ciphertext in SQL, so you can't filter or sort by its plaintext — encrypt only what you read back whole (PII, tokens, notes). A book search never touches bio, so this is safe.
The column ONLY works because app.config.ts wires governancePlugin({ fieldEncryption: true }), which registers the cipher. Boot fails loud if an .encrypted() column exists but no cipher is registered.
// app.config.ts
import { defineEnv, envVar } from '@voltro/env'
import { governancePlugin } from '@voltro/plugin-governance'
export const env = defineEnv({
LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
// NO `default` on purpose — see the note below.
VOLTRO_FIELD_ENCRYPTION_KEY: envVar.string({ access: 'secret' }),
})
export default {
type: 'api' as const,
name: '{{capProjectName}}{{capAppName}}',
store: 'memory' as const,
env,
plugins: [
governancePlugin({ fieldEncryption: true }), // reads secret VOLTRO_FIELD_ENCRYPTION_KEY
],
}Where the key actually comes from is the load-bearing detail. governancePlugin({ fieldEncryption: true }) resolves the AES-256-GCM key through the Secrets-Resolver — i.e. the process environment — NOT through a defineEnv default. A defineEnv default would satisfy the boot validation gate while the cipher still failed to resolve a key. That's why the env declaration above deliberately has no default, and the template instead ships a .env carrying a DEV-ONLY placeholder so voltro dev boots out of the box:
# .env — DEV-ONLY. `voltro dev` loads this into process.env at boot, BEFORE
# the env-validation gate and BEFORE plugins activate, which is how the
# governance plugin's cipher resolves its key from the Secrets-Resolver.
VOLTRO_FIELD_ENCRYPTION_KEY=00112233445566778899aabbccddeeff00112233445566778899aabbccddeeffBefore production, replace this placeholder with a real key (
openssl rand -hex 32) and move it into your deployment's secret store — never ship the shipped value. Lose the key → lose the data (GCM fails closed, never silent corruption); rotating it makes every existing.encrypted()value unreadable.
See Field encryption and the columns reference.
Declarative query caching
books.search adds cache to its defineQuery. The framework caches the server snapshot and auto-invalidates it whenever a mutation writes any table the query depends on (here, books) — zero manual busting. scope is REQUIRED and is a security decision: this query is tenant-filtered (the tenant() mixin AND-merges the caller's tenantId), so it depends on the caller → scope: 'subject' (the cache key includes the subject id, no cross-tenant leak). NEVER global on a subject-filtered query.
// queries/books.search.query.ts — descriptor (browser-safe)
import { defineQuery } from '@voltro/protocol'
import { Schema } from 'effect'
export const searchBooks = defineQuery({
name: 'books.search',
source: 'books',
input: Schema.Struct({ q: Schema.String }),
output: Schema.Struct({
id: Schema.String, authorId: Schema.String, title: Schema.String,
summary: Schema.String, genre: Schema.String,
tags: Schema.Array(Schema.String), slug: Schema.String, tenantId: Schema.String,
}),
cache: { ttl: '30s', swr: '5m', scope: 'subject' },
})The authors.withBooks query deliberately has NO cache — it's a join-shaped result, so the template lets the live dispatcher keep it fresh rather than cache a snapshot. See Query result caching.
Boot seed
catalog.seed.ts is a boot-lifecycle seed that idempotently upsertByUniques a demo tenant, two authors, and three books, so the catalog has data the moment voltro dev boots. The seed store is the RAW store (no authenticated subject), so tenant() auto-fill doesn't fire — it passes tenantId: 'acme' explicitly (matching the dev AuthMiddleware default, which resolves acme from the x-tenant header). It passes bio as plaintext (the middleware encrypts it), tags as a JS array, and OMITS slug (the .generatedAs(...) STORED column is filled by the DB engine). See Seeds.
Try it
Subscribe from a web app via useSubscription('app', 'books.search', { q: 'magic' }) and useSubscription('app', 'authors.withBooks'), or invoke them over HTTP through POST /_voltro/inspect/invoke for a one-shot snapshot.
When to use api-data-advanced vs. the variants
| You need… | Pick |
|---|---|
| The smallest reference to extend | api-backend |
| A worked tour of the advanced schema DSL (relations + eager loading, FTS, enum/array/generated/encrypted columns, query cache) | api-data-advanced |
| Transactional email wired (React-Email) | api-backend-mail |
| File storage wired (public + private objects) | api-backend-storage |
| MariaDB binlog CDC + storage (K8s shape) | api-backend-mariadb |
Pairs well with
- Any web template —
api-data-advancedexposes its queries over the standard rpc client. @voltro/plugin-governance— already wired here for field encryption; it also ships retention sweeps, GDPR export/erase, and a consent ledger.
Anti-patterns
- Shipping the placeholder
VOLTRO_FIELD_ENCRYPTION_KEYto production. The.envvalue is a public, DEV-ONLY constant. Generate a real key (openssl rand -hex 32), store it in your deployment's secret store, and never commit it. Lose the key and the ciphertext is gone for good. - Expecting a
defineEnvdefaultto feed the cipher. It won't — the cipher reads the Secrets-Resolver (process env), not the typed-env default. A default would pass the boot gate while the cipher still fails to resolve, so the key must reachprocess.env(here via.env). - Filtering or sorting by an
.encrypted()column's plaintext in SQL. It's ciphertext on disk — those predicates can't run. Encrypt only fields you read back whole. - Adding
tenantIdfilters by hand in the query executors.authorsandbookscarrytenant(), so the runtime scopes reads automatically. Doing both works but signals you don't trust the framework. - Passing
slugin a write. It's a.generatedAs(...)STORED column — the DB engine fills it fromtitle. Passing a value fights the generator.