# {{projectName}} / {{appName}}

Voltro backend scaffold (template: **api-data-advanced**).

A small **library/catalog** (authors + books) that closes the advanced
schema-DSL gaps — features that are documented but shown in no other
template: the `*.entity.ts` / `*.relations.ts` split with eager loading,
full-text search, `dbEnum`, array / generated / encrypted columns, and
declarative query caching.

## Boot

```bash
pnpm install                # at the repo root
pnpm --filter @{{projectName}}/{{appName}} dev
# → http://localhost:4000
# → ws://localhost:4000/ws
```

Boots with **zero setup**: the `.encrypted()` `authors.bio` column needs an
AES-256-GCM key, and the shipped `.env` supplies a **DEV-ONLY** placeholder so
`voltro dev` comes up immediately. `voltro dev` then auto-migrates the schema
and runs the boot seed, so the catalog has two authors + three books right away.

> ⚠️ **Before production**, replace the key in `.env` with a real one and move
> it into your deployment's secret store — never ship the placeholder. Generate
> one with `openssl rand -hex 32`. **Lose the key → lose the ciphertext** (GCM
> fails closed); **rotating it** makes every existing `.encrypted()` value
> unreadable.

## Environment

| Var | Access | Why |
|---|---|---|
| `VOLTRO_FIELD_ENCRYPTION_KEY` | secret | AES-256-GCM key for the `.encrypted()` `authors.bio` column (32-byte hex). `governancePlugin({ fieldEncryption: true })` resolves it through the **Secrets-Resolver** — i.e. the process environment, which the shipped `.env` populates (a `defineEnv` `default` would NOT reach the cipher). Boot fails loud if an `.encrypted()` column exists but the cipher can't resolve a key. |
| `LOG_LEVEL` | public | `debug \| info \| warn \| error` (default `info`). |

## What this demonstrates

| Feature | Where | Notes |
|---|---|---|
| `*.entity.ts` / `*.relations.ts` split | `database/*.entity.ts`, `database/*.relations.ts` | One table per file; relations declared OUTSIDE the table descriptor. |
| `relations()` + eager `.with()` | `authors.relations.ts`, `books.relations.ts`, `queries/authors.withBooks.query.server.ts` | `database.authors.with({ books: true })` → each author carries a `books[]` array in ONE SQL roundtrip. |
| Full-text search | `books.entity.ts` (`.fullTextIndex('bookSearch', ['title','summary'])`) + `queries/books.search.query.server.ts` (`.matching('bookSearch', q)`) | One declaration, three backends (postgres tsvector, mysql/mariadb FULLTEXT, sqlite FTS5, mssql ranked-LIKE). |
| `dbEnum(...)` | `books.entity.ts` (`bookGenre`) | Native `CREATE TYPE … ENUM` on postgres; native `ENUM(...)` on mysql/mariadb; CHECK fallback elsewhere. |
| `array(text())` | `books.entity.ts` (`tags`) | Native `text[]` on postgres; JSON-string codec on other dialects — the API stays "a JS array". |
| `.generatedAs(expr, { stored })` | `books.entity.ts` (`slug`) | DB-computed, persisted, indexable column derived from `title`. Don't write it — the engine fills it. |
| `.encrypted()` | `authors.entity.ts` (`bio`) | Transparent AES-256-GCM at rest via `governancePlugin({ fieldEncryption: true })` in `app.config.ts`. |
| Declarative query caching | `queries/books.search.query.ts` (`cache: { ttl, swr, scope }`) | Server snapshot cache with auto-invalidation on writes to `books`; `scope: 'subject'` because the query is tenant-filtered. |
| Boot seed | `seeds/catalog.seed.ts` | Idempotent `upsertByUnique` authors + books. |

## Files

```
app.config.ts                          governancePlugin({ fieldEncryption: true })
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 + books
```

## Try it

The dev AuthMiddleware resolves the `acme` tenant from the `x-tenant`
header (the seed writes under `acme`). Subscribe to the queries 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.

## Switching to a real SQL store

`store: 'memory'` keeps boot zero-infra. Switch `app.config.ts` to
`store: 'postgres'` (and `docker compose up` a postgres) to see the
native DDL the migrator emits for each feature — `CREATE TYPE`,
`text[]`, the STORED generated column, and the tsvector + GIN FTS index.
The handler code is unchanged across every dialect.

Drop more `*.entity.ts` / `*.query.ts` / `*.mutation.ts` / `*.action.ts`
files anywhere in this tree — discovery is by file convention.
