fastify-auto-sqlapi 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS_BACKEND.md CHANGED
@@ -1,546 +1,106 @@
1
- # fastify-auto-sqlapi — Backend Configuration
1
+ # fastify-auto-sqlapi — backend reference (LLM)
2
2
 
3
- How to set up schemas, tables, and configure the plugin.
3
+ Setting up the plugin and configuring tables. Client request grammar: AGENTS_FRONTEND.md. Migrating from `joinFilters`/`joins`/`joinGroups`: BREAKING_CHANGES.md.
4
4
 
5
- > ⚠️ **Migrating from a previous version?** The join API was redesigned (no backward compat). See **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)** for the full migration guide — request/response key renames, `buildRelation` signature, and validation rules. Common to backend and frontend.
5
+ ## Rules
6
6
 
7
- ## Section map
7
+ - Everything is a camelCase **schema field name** (request/response bodies, `defineTable` keys, hooks, `validate`, `sqlApi.*`, `buildRelation`). **Real DB column names** only where you talk to the database yourself: `db.*` (QueryClient), `tenantScope`, and any SQL string you write. Get them with `Schema.col('field')` / `qiCol('field')`, never type them: columns may be snake_case or camelCase, the generated Schema knows which.
8
+ - `sqlApi` = high-level (camelCase, validation, hooks, tenant, joins, computed). `db` = the SQL layer under it (real names, none of that). Not interchangeable.
9
+ - Do not read `node_modules/fastify-auto-sqlapi/dist/`: internal, unstable, not documentation. If this file does not answer, say so.
10
+ - Behaviors that look like bugs but are decisions (docs/adr/): default-open `operations`, non-transactional bulk, every field updatable, raw 500 on DB errors, joins not running the target's route guards, no conditional/mass update route. Do not "fix" them.
8
11
 
9
- This file is long — jump, don't scan. Every supported behavior is described here or in the
10
- files [AGENTS.md](./AGENTS.md) links to; the compiled `dist/` is not documentation and must not
11
- be read to answer a question about the API.
12
+ ## CLI: generate Schemas and tables
12
13
 
13
- | Section | Answers |
14
- |---|---|
15
- | [Setup Workflow](#setup-workflow) | install, `sqlapi.config.ts`, env vars, generating Schemas and tables, registering the plugin |
16
- | [Plugin Options](#plugin-options) | every `register()` option with its default, and how the connection is picked up (`fastify.pg` / `fastify.mysql`) |
17
- | [defineTable() — Complete Reference](#definetable--complete-reference) | every table key, composite PKs, `buildRelation`, `extraFilters` + `extendedCondition` |
18
- | [Computed Fields](#computed-fields-extension-system) | virtual SQL-expression fields usable in filters, conditions, orderBy, `selectComputed` |
19
- | [ConditionBuilder API](#conditionbuilder-api) | building WHERE fragments by hand |
20
- | [SqlApi](#sqlapi--programmatic-high-level-api) | **querying from your own routes, scripts and jobs** — `app.sqlApi`, `createSqlApi` |
21
- | [QueryClient API](#queryclient-api) | the raw SQL layer under SqlApi — `db.*` inside hooks |
22
- | [Swagger](#swagger) | exposing the generated docs |
23
- | [Multi-Tenant Filtering](#multi-tenant-filtering) | `tenantScope` direct / indirect / `anyOf`, `getTenantId`, admin bypass |
24
- | [Views](#views) | views and materialized views as table configs, the primary key a view cannot declare, why writes start off |
25
- | [Key Conventions](#key-conventions) | casing, nullability, `excludeFromCreation`, `readExclude`, `upsertMap`, `schemaOverrides`, `afterRead`, error shape, request limits |
26
- | [Common Backend Patterns](#common-backend-patterns) | auth, custom validation, audit fields, encryption at rest, full-text search, per-prefix access levels |
27
- | [Re-exports](#re-exports) | what the package re-exports, so nothing extra has to be installed |
28
- | [FAQ / Gotchas](#faq--gotchas) | schema exports, prefix behavior, TypeBox conflicts, dialect differences |
29
-
30
- **Casing in one line**: everything you write is camelCase (schema field names) except raw SQL,
31
- `tenantScope` columns and `db.*` record keys, which name the real columns — in whatever case the
32
- table uses. Full table in
33
- [AGENTS.md](./AGENTS.md#naming-camelcase-in-code-real-column-names-only-when-you-address-the-db-directly).
34
-
35
- ## Setup Workflow
36
-
37
- ### 1. Install
38
-
39
- ```bash
40
- npm install fastify-auto-sqlapi
41
- # PostgreSQL:
42
- npm install fastify @fastify/postgres
43
- # MySQL/MariaDB:
44
- npm install fastify mysql2
45
- # Optional (for Swagger UI):
46
- npm install @fastify/swagger @fastify/swagger-ui
47
- ```
48
-
49
- ### 2. Configure `sqlapi.config.ts` (CLI only)
50
-
51
- Create `sqlapi.config.ts` (or `.js`) in the project root. This config is used **only by the CLI** (`sqlapi-generate-schema` / `sqlapi-generate-tables`), NOT at runtime by the Fastify plugin.
52
-
53
- ```typescript
54
- // sqlapi.config.ts
14
+ ```ts
15
+ // sqlapi.config.ts — CLI only, never read at runtime. These six keys are all that exist.
55
16
  export default {
56
- outputDir: './src/db', // base directory for generated files (default: './src/db')
57
- schema: 'public', // DB schema to introspect (default: 'public', PostgreSQL only)
58
- dialect: 'postgres', // 'postgres' | 'mysql' | 'mariadb' (default: 'postgres')
59
- // envFile: '../../.env', // path to .env file, relative to cwd (default: '.env')
60
- // excludeTables: ['knex_*'],// tables invisible to schema generation (see below)
61
- // excludeColumns: ['*.password_hash'], // columns invisible to schema generation (see below)
17
+ outputDir: './src/db', // default './src/db'
18
+ schema: 'public', // PostgreSQL only
19
+ dialect: 'postgres', // 'postgres' | 'mysql' | 'mariadb'; or --dialect flag
20
+ envFile: '.env', // relative to cwd; useful in monorepos
21
+ excludeTables: ['knex_*'], // DB table names or * globs: no schema generated, old one removed
22
+ excludeColumns: ['*.password_hash', 'user.internal_*'], // "<table>.<column>" DB names, * globs. The column does not exist for the API at all (no read, write, filter, manifest). Cannot exclude a PK; a pattern leaving a table with no columns fails the run.
62
23
  };
63
24
  ```
64
25
 
65
- That's it — only these six fields exist. **No connection string, no migration path.** If the file is missing, defaults are used. The `dialect` can also be passed via CLI flag `--dialect mysql`. The `envFile` is useful in monorepo setups where the `.env` lives at the repo root.
66
-
67
- **`excludeTables`** keeps migration bookkeeping, PostGIS internals and the like out of generation: exact table names or `*` globs, matched against the **DB table name** (not the generated schema name), with everything else literal. An excluded table is invisible to the generator — its schema file is not created, and one generated by an earlier run is removed as an orphan on the next full run, exactly like the schema of a dropped table. See [ADR 0009](./docs/adr/0009-schema-exclusion-config-blacklist.md).
68
-
69
- **`excludeColumns`** does the same for single columns: `"<table>.<column>"` entries matched against the **DB names** (not the generated field names), with `*` as a wildcard on either side — `'user.password_hash'`, `'*.internal_*'`, `'audit_*.raw_payload'`. An excluded column does not exist for the API at all: it is not in the Schema, so no route reads, writes, filters or orders by it, and the manifest never lists it. This is the lever for *"this column must not exist for the API"*; `readExclude` / `writeExclude` on the table config are for a column that stays in the Schema and loses one side. A primary key column cannot be excluded (every route identifies rows with it), and a pattern that would leave a table with no columns fails the run: that is `excludeTables`' job. Never edit a generated Schema file by hand — it is overwritten on the next run. See [ADR 0019](./docs/adr/0019-column-exclusion-is-generation-time-config.md).
70
-
71
- ### 3. Configure database connection (env vars)
72
-
73
- The CLI reads DB connection from environment variables (NOT from the config file). `DATABASE_URL` takes priority for all dialects.
74
-
75
- **PostgreSQL:**
76
- - `DATABASE_URL` — full connection string, OR:
77
- - `POSTGRES_HOST` (default: `127.0.0.1`), `POSTGRES_PORT` (default: `5433`), `POSTGRES_USER` (default: `test`), `POSTGRES_PASSWORD` (default: `test`), `POSTGRES_DB` (default: `testdb`)
78
-
79
- **MySQL/MariaDB:**
80
- - `DATABASE_URL` — full connection string, OR:
81
- - `MYSQL_HOST` (default: `127.0.0.1`), `MYSQL_PORT` (default: `3306`), `MYSQL_USER` (default: `test`), `MYSQL_PASSWORD` (default: `test`), `MYSQL_DB` (default: `testdb`)
82
-
83
- The CLI automatically loads a `.env` file from the current working directory (if it exists). Variables already set in the environment are not overridden.
84
-
85
- ```
86
- # .env
87
- DATABASE_URL=postgres://user:pass@localhost:5432/mydb
88
- ```
89
-
90
- ```json
91
- "scripts": {
92
- "sqlapi:generate-schema": "sqlapi-generate-schema",
93
- "sqlapi:generate-tables": "sqlapi-generate-tables --all"
94
- }
95
- ```
96
-
97
- At runtime, the Fastify plugin uses `@fastify/postgres` (for PG) or `mysql2` (for MySQL/MariaDB) which you configure separately (see step 6).
98
-
99
- ### 4. Generate Schema files
26
+ Connection comes from env (a `.env` in cwd is loaded, set vars win): `DATABASE_URL`, or `POSTGRES_HOST|PORT|USER|PASSWORD|DB` / `MYSQL_HOST|PORT|USER|PASSWORD|DB`.
100
27
 
101
28
  ```bash
102
- npx sqlapi-generate-schema # PostgreSQL (default)
103
- npx sqlapi-generate-schema --dialect mysql # MySQL/MariaDB
29
+ npx sqlapi-generate-schema [--dialect mysql] # src/db/schemas/Schema*.ts — generated, NEVER edit (overwritten)
30
+ npx sqlapi-generate-tables customer order # src/db/tables/Table*.ts + dbTables.ts — yours to edit
31
+ npx sqlapi-generate-tables --all # existing files are skipped, never overwritten
104
32
  ```
105
33
 
106
- This introspects the database and generates one `Schema*.ts` file per table in `outputDir/schemas/`. These files are auto-generated and should not be manually edited. They contain TypeBox field definitions, `col()` mapping each camelCase field to its real column, and validation schemas. MySQL/MariaDB requires `mysql2` as a peer dependency.
34
+ - The generated table files detect PKs, `*Id` foreign keys as relations, and put every other key in as commented code.
35
+ - The schema CLI prints what it could not decide for you: read that output. It covers database-generated columns (`GENERATED ALWAYS AS`, put in the Schema's `generatedFields` and never writable) and identity/auto-increment PKs (made optional and listed in `excludeFromCreation`). It also prints each view's inferred PK (see Views).
36
+ - Schema files import `Type` from `fastify-auto-sqlapi` to avoid duplicate-TypeBox `[Kind]` errors. Their default export (`addSchema`) is optional, only for Swagger `$ref`.
107
37
 
108
- ### 5. Generate tables template
38
+ ## Server setup
109
39
 
110
- ```bash
111
- npx sqlapi-generate-tables customer customer_order # specific tables (space separated)
112
- npx sqlapi-generate-tables customer,customer_order # specific tables (comma separated)
113
- npx sqlapi-generate-tables --all # all tables
114
- ```
115
-
116
- This reads the Schema files from `outputDir/schemas/` and generates one `Table*.ts` file per table plus a `dbTables.ts` index in `outputDir/tables/`:
117
-
118
- ```
119
- src/db/
120
- schemas/
121
- SchemaCustomer.ts # from generate-schema (do not edit)
122
- SchemaCustomerOrder.ts
123
- tables/
124
- TableCustomer.ts # generated — skip if already exists
125
- TableCustomerOrder.ts
126
- dbTables.ts # generated — skip if already exists
127
- ```
128
-
129
- Each `Table*.ts` file contains a `defineTable()` call with:
130
- - Auto-detected primary keys
131
- - Auto-detected foreign key relations (from field naming convention `*Id`)
132
- - Commented example of `extraFiltersValidation` + `extendedCondition`
133
- - All optional keys as commented code, ready to uncomment
134
- - `export const TableXxx` (named export)
135
-
136
- **No files are ever overwritten** — if they already exist, they are skipped. This makes it safe to re-run the command when new tables are added to the database. Import paths are generated without extensions (the consumer's tsconfig decides resolution).
137
-
138
- **Edit the `Table*.ts` and `dbTables.ts` files to customize** — these files are yours to maintain.
139
-
140
- ### 6. Create the Fastify server
141
-
142
- **PostgreSQL setup:**
143
-
144
- ```typescript
40
+ ```ts
145
41
  import Fastify from 'fastify';
146
42
  import fastifyPostgres from '@fastify/postgres';
147
43
  import { fastifyAutoSqlApi } from 'fastify-auto-sqlapi';
148
44
  import { dbTables } from './src/db/tables/dbTables.js';
149
45
 
150
46
  const app = Fastify();
151
-
152
- await app.register(fastifyPostgres, {
153
- connectionString: 'postgres://user:pass@localhost:5432/mydb',
154
- });
155
-
47
+ await app.register(fastifyPostgres, { connectionString: process.env.DATABASE_URL }); // → fastify.pg
156
48
  await app.register(fastifyAutoSqlApi, {
157
- DbTables: dbTables, // REQUIRED — Record<string, ITable>
158
- // dialect: 'postgres', // optional — default is 'postgres'
159
- swagger: true, // optional — true or SwaggerOptions object
160
- prefix: '/auto', // optional — standard Fastify prefix
161
- onRequests: [], // optional — global auth hooks (run on every route)
162
- getTenantId: (request) => request.user?.organizationId ?? null, // optional — multi-tenant
163
- maxItemsPerPage: 1000, // optional — cap on search page size / no-paginator LIMIT (default 1000)
164
- maxBulkItems: 1000, // optional — cap on bulk array length (default 1000)
165
- debug: true, // optional — log all SQL queries and params to console
49
+ DbTables: dbTables, // required. Record key = route name: { customer } → /search/customer
50
+ dialect: 'postgres', // 'postgres' (default) | 'mysql' | 'mariadb'
51
+ prefix: '/auto', // standard Fastify prefix, applied once. None → /search/customer
52
+ swagger: true, // or { title, description, version, routePrefix }. Needs @fastify/swagger + @fastify/swagger-ui, else warns and continues
53
+ onRequests: [auth], // global hooks on every route
54
+ getTenantId: (req) => req.user?.organizationId ?? null, // see Tenant scope
55
+ maxItemsPerPage: 1000, // search page cap AND the LIMIT when no paginator is sent → 400 above it
56
+ maxBulkItems: 1000, // bulk array cap → 400 above it
57
+ agentManifest: true, // GET {prefix}/agent/manifest(.md), behind the same onRequests
58
+ debug: true, // log SQL server-side; never changes a response
59
+ exposeDebugInfo: false, // DEV ONLY: add driver error (message, code, constraint, detail, stack) as `debugInfo` on 500s
166
60
  });
61
+ // app.sqlApi is now decorated, usable from any route.
167
62
 
168
- await app.listen({ port: 3000 });
169
- ```
170
-
171
- **MySQL/MariaDB setup:**
172
-
173
- ```typescript
174
- import Fastify from 'fastify';
175
- import mysql from 'mysql2/promise';
176
- import { fastifyAutoSqlApi, mysqlQueryable } from 'fastify-auto-sqlapi';
177
- import { dbTables } from './src/db/tables/dbTables.js';
178
-
179
- const app = Fastify();
180
-
181
- const pool = mysql.createPool({
182
- host: '127.0.0.1', port: 3306,
183
- user: 'root', password: 'pass', database: 'mydb',
184
- });
63
+ // MySQL/MariaDB: decorate a mysql2/promise pool as `mysql` instead of registering @fastify/postgres
64
+ const pool = mysql.createPool({ host, user, password, database });
185
65
  app.decorate('mysql', pool);
66
+ app.addHook('onClose', () => pool.end());
67
+ await app.register(fastifyAutoSqlApi, { DbTables: dbTables, dialect: 'mysql' });
186
68
 
187
- await app.register(fastifyAutoSqlApi, {
188
- DbTables: dbTables,
189
- dialect: 'mysql', // or 'mariadb' (MariaDB supports RETURNING)
190
- swagger: true,
191
- prefix: '/auto',
192
- });
193
-
194
- await app.listen({ port: 3000 });
195
- ```
196
-
197
- **Granular composition (any dialect):**
198
-
199
- ```typescript
200
- import {
201
- searchRoutes, getRoutes, insertRoutes, updateRoutes,
202
- deleteRoutes, bulkUpsertRoutes, bulkDeleteRoutes, setupSwagger,
203
- } from 'fastify-auto-sqlapi';
204
-
69
+ // Register only specific routes / different access levels per prefix. Each route plugin works standalone and decorates its own scoped sqlApi; register the main plugin if your own routes need app.sqlApi.
70
+ import { searchRoutes, getRoutes, insertRoutes, updateRoutes, deleteRoutes, bulkUpsertRoutes, bulkDeleteRoutes, agentManifestRoutes, setupSwagger } from 'fastify-auto-sqlapi';
205
71
  await app.register(async (instance) => {
206
72
  await setupSwagger(instance, { swagger: true });
207
-
208
- const opts = { DbTables: dbTables, dialect: 'mysql' };
209
- await instance.register(searchRoutes, opts);
73
+ const opts = { DbTables: publicTables, dialect: 'postgres' };
74
+ await instance.register(searchRoutes, opts); // read-only: no insert/update/delete/bulk
210
75
  await instance.register(getRoutes, opts);
211
- await instance.register(insertRoutes, opts);
212
- await instance.register(updateRoutes, opts);
213
- await instance.register(deleteRoutes, opts);
214
- await instance.register(bulkUpsertRoutes, opts);
215
- await instance.register(bulkDeleteRoutes, opts);
216
- }, { prefix: '/auto' });
217
- ```
218
-
219
- **Note**: After registering the plugin, `app.sqlApi` is available for custom routes — see [SqlApi](#sqlapi--programmatic-high-level-api).
220
-
221
- **Note**: Granular route plugins create their own `sqlApi` decorator automatically, so they work standalone (no main plugin required). The decorator is scoped to each route plugin: if you also need `app.sqlApi` in your own routes, register the main plugin.
222
-
223
- ---
224
-
225
- ## Plugin Options
226
-
227
- ```typescript
228
- await app.register(fastifyAutoSqlApi, {
229
- DbTables: dbTables, // Required — your table definitions
230
- dialect: 'postgres', // Optional — 'postgres' | 'mysql' | 'mariadb' (default: 'postgres')
231
- prefix: '/api', // Optional — URL prefix for all routes
232
- swagger: true, // Optional — enable Swagger UI (or pass SwaggerOptions)
233
- onRequests: [authMiddleware], // Optional — global hooks applied to every route
234
- getTenantId: (req) => id, // Optional — multi-tenant function
235
- maxItemsPerPage: 1000, // Optional — cap on search page size (default: 1000)
236
- maxBulkItems: 1000, // Optional — cap on bulk array length (default: 1000)
237
- debug: true, // Optional — log all SQL queries and params
238
- });
76
+ }, { prefix: '/public' });
239
77
  ```
240
78
 
241
- | Option | Type | Required | Description |
242
- |--------|------|----------|-------------|
243
- | `DbTables` | `Record<string, ITable>` | Yes | Table configurations |
244
- | `dialect` | `'postgres' \| 'mysql' \| 'mariadb'` | No | DB dialect (default: `'postgres'`) |
245
- | `prefix` | `string` | No | URL prefix (e.g. `/api`) |
246
- | `swagger` | `boolean \| SwaggerOptions` | No | Enable Swagger UI |
247
- | `onRequests` | `Function[]` | No | Global auth/middleware hooks |
248
- | `getTenantId` | `(req) => id \| null` | No | Tenant resolver for multi-tenant |
249
- | `maxItemsPerPage` | `number` | No | Max search page size, and the row `LIMIT` applied when no paginator is sent (default: `1000`) |
250
- | `maxBulkItems` | `number` | No | Max number of items accepted by the bulk endpoints (default: `1000`) |
251
- | `debug` | `boolean` | No | Log all SQL queries to console (server-side only — it never changes a response) |
252
- | `exposeDebugInfo` | `boolean` | No | **Development only.** Attach the driver error (message, `code`, `constraint`, stack) to `500` responses as `debugInfo` (default: `false`) |
253
-
254
- The plugin picks up the connection from the Fastify instance: `fastify.pg` for PostgreSQL
255
- (what `@fastify/postgres` decorates) or `fastify.mysql` for MySQL/MariaDB. For MySQL,
256
- decorate a `mysql2/promise` pool (e.g. via `@fastify/mysql` with `promise: true`, or manually)
257
- and pass `dialect: 'mysql'` or `'mariadb'`:
258
-
259
- ```typescript
260
- import mysql from 'mysql2/promise';
261
-
262
- const pool = mysql.createPool({ host: '127.0.0.1', user: 'myuser', password: '...', database: 'mydb' });
263
- app.decorate('mysql', pool);
264
- app.addHook('onClose', async () => { await pool.end(); });
79
+ `pg` returns `numeric`/`int8` as strings and shifts `date`/`timestamp` to local time. The plugin leaves `pg.types` alone (global state). If you want numbers and verbatim dates, set this at startup:
265
80
 
266
- await app.register(fastifyAutoSqlApi, { DbTables: dbTables, dialect: 'mysql' });
81
+ ```ts
82
+ pg.types.setTypeParser(pg.types.builtins.INT8, (v) => parseInt(v, 10));
83
+ pg.types.setTypeParser(pg.types.builtins.NUMERIC, (v) => parseFloat(v));
84
+ pg.types.setTypeParser(pg.types.builtins.DATE, (v) => v);
85
+ pg.types.setTypeParser(pg.types.builtins.TIMESTAMP, (v) => v);
267
86
  ```
268
87
 
269
- ---
270
-
271
- ## defineTable() — Complete Reference
88
+ HTTP responses coerce `"42"` to a number anyway. The parsers matter for `sqlApi.*` results and for hook arithmetic.
272
89
 
273
- ```typescript
274
- import {defineTable, exportTableInfo, Type} from 'fastify-auto-sqlapi';
275
- // Import additional utilities only when needed:
276
- // import {buildRelation, buildUpsertRules, buildUpsertRule, ConditionBuilder} from 'fastify-auto-sqlapi';
277
- // import type {DbTables} from 'fastify-auto-sqlapi';
278
- ```
90
+ ## defineTable
279
91
 
280
- ### Minimal table
92
+ Every key except `primary` and `exportTableInfo` is optional. There is no `tableName` key: the SQL table is `Schema.tableName`, and the route/manifest name is the `dbTables` key. One physical table can therefore be exposed under several keys, each with its own `operations`, `onRequests`, `tenantScope`, `readExclude` and joins.
281
93
 
282
- ```typescript
283
- import {defineTable, exportTableInfo} from 'fastify-auto-sqlapi';
284
- import {SchemaCustomer as Schema} from '../schemas/SchemaCustomer';
94
+ ```ts
95
+ import { defineTable, exportTableInfo, buildRelation, buildUpsertRules, buildUpsertRule, ConditionBuilder, Type } from 'fastify-auto-sqlapi';
96
+ import type { ValidationError } from 'fastify-auto-sqlapi';
97
+ import { SchemaCustomer as Schema } from '../schemas/SchemaCustomer';
285
98
 
286
99
  export const TableCustomer = defineTable({
287
- primary: 'id',
288
- ...exportTableInfo(Schema),
289
- });
290
- ```
291
-
292
- `exportTableInfo(Schema)` returns `{ Schema, filters, extraFilters }`. The `filters` function auto-builds WHERE conditions from any schema field present in the request.
293
-
294
- **Important**: there is no `tableName` key in `defineTable()`. The SQL table name comes from `Schema.tableName` (set by the CLI). The route URL name is the key you use in the `dbTables` record (e.g. `{ customer: TableCustomer }` → `/search/customer`). The key also names the table in search responses (`table`) and in the agent manifest.
100
+ primary: 'id', // or ['agentId', 'teamId']. Composite PK: no get/delete/bulkDelete routes (listing them in `operations` throws, sqlApi.* → 400); use search, update (matches all PK columns), or a custom route.
295
101
 
296
- Because routes follow the key, the same physical table can be exposed by several table configs, each with its own `operations`, `onRequests`, `tenantScope`, `readExclude` and joins:
297
-
298
- ```typescript
299
- export const dbTables: DbTables = {
300
- conversation: TableConversation, // owner: list, rename, delete
301
- sharedConversation: TableSharedConversation, // anyone with the link: search only, shared rows only
302
- conversationHistory: TableConversationHistory, // server-side only: operations: []
303
- };
304
- // → /search/conversation, /rest/conversation, /search/sharedConversation — all SQL on "conversation"
305
- ```
306
-
307
- ### Composite primary key (link/junction tables)
308
-
309
- ```typescript
310
- import {defineTable, exportTableInfo} from 'fastify-auto-sqlapi';
311
- import {SchemaAgentTeamLink as Schema} from '../schemas/SchemaAgentTeamLink';
312
-
313
- export const TableAgentTeamLink = defineTable({
314
- primary: ['agentId', 'teamId'],
315
- ...exportTableInfo(Schema),
316
- });
317
- ```
318
-
319
- When `primary` is an array, RETURNING clauses include all PK columns, and response shapes contain all PK fields.
320
-
321
- **By-single-id operations are not available for composite PKs**: `get`, `delete` and `bulkDelete` address a record by one PK value, so their routes are skipped for composite-PK tables (matching on the first column alone could hit many rows — destructively for the deletes). Explicitly listing one of them in `operations` throws at startup, and the programmatic `sqlApi.get/delete/bulkDelete` reject with 400. Use `search` (all PK fields as filters), `update` (matches every PK column), or a custom route.
322
-
323
- ### All keys
324
-
325
- ```typescript
326
- export const TableCustomer = defineTable({
327
- // REQUIRED
328
- primary: 'id', // PK field name (camelCase), or array for composite: ['agentId', 'teamId']
329
- ...exportTableInfo(Schema), // Schema + auto-filter builder
330
-
331
- // OPTIONAL
332
- defaultOrder: 'name', // ORDER BY default. camelCase fields are mapped to DB
333
- // columns (e.g. 'squadIndex' -> "squad_index"); supports
334
- // multi ('name ASC, id DESC'), computed fields, and raw
335
- // SQL fragments (unknown tokens pass through unchanged)
336
- excludeFromCreation: ['id'], // Fields omitted from INSERT — MUST include auto-increment PKs
337
- readExclude: ['passwordHash'], // Fields hidden from ALL reads (writes unaffected)
338
- distinctResults: true, // Use SELECT DISTINCT
339
-
340
- // JOINS — relations to other tables. Alias defaults to joinSchema.tableName —
341
- // declare it explicitly only when joining the same table twice or when you want
342
- // a friendlier name. Set `unique: true` for N:1 (parent) relations to enable `joinLeft`.
343
- allowedReadJoins: [ // Available for search: joinMustExist / joinMultiple / joinGroup / joinLeft
344
- buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order' (default)
345
- buildRelation(SchemaCustomer, 'id', SchemaAddress, 'customerId', { selection: 'id, city, zip' }), // alias = 'address'
346
- // N:1 example (enables joinLeft):
347
- // buildRelation(SchemaSession, 'userId', SchemaUser, 'id', { unique: true }), // alias = 'user'
348
- // Alias multipli sulla stessa tabella (richiede alias espliciti):
349
- // buildRelation(SchemaSession, 'createdBy', SchemaUser, 'id', { alias: 'creator', unique: true }),
350
- // buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true }),
351
- ],
352
- allowedWriteJoins: [ // Available for insert/update secondaries (alias is the request body key)
353
- buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
354
- ],
355
-
356
- // UPSERT — ON CONFLICT resolution
357
- upsertMap: buildUpsertRules(
358
- buildUpsertRule(SchemaCustomer, ['id']), // main table conflict key
359
- buildUpsertRule(SchemaOrder, ['id']), // secondary conflict key
360
- ),
361
-
362
- // TENANT — automatic row-level isolation
363
- tenantScope: { column: 'organization_id' }, // direct: column on this table
364
- // OR indirect: resolve via JOIN to parent table
365
- // tenantScope: {
366
- // column: 'organization_id',
367
- // through: { schema: SchemaCustomer, localField: 'customerId', foreignField: 'id' },
368
- // },
369
- // OR anyOf: several owner columns, the row is visible to any of them
370
- // tenantScope: { anyOf: ['requester_agent_id', 'target_agent_id'] },
371
-
372
- // SCHEMA OVERRIDES — tighten auto-generated schema validation without editing Schema files
373
- schemaOverrides: {
374
- email: Type.String({ format: 'email' }),
375
- name: Type.String({ minLength: 1, maxLength: 100 }),
376
- },
377
-
378
- // VALIDATION — structured field-level validation (runs after schema, before hooks)
379
- validate: async (db, req, main, secondaries) => {
380
- // Return ValidationError[] — tuple: [field, code] or [field, code, message]
381
- // message defaults to code if omitted
382
- const errors = [];
383
- if (main.name === '') {
384
- errors.push(['name', 'required', 'cannot be empty']);
385
- }
386
- // Cross-entity: validate secondaries (e.g. date overlap in periods).
387
- // Note: secondaries are keyed by alias (declared in allowedWriteJoins), not by tableName.
388
- if (secondaries?.orders) {
389
- // ... check overlaps, business rules across related records
390
- }
391
- return errors;
392
- },
393
- validateBulk: async (db, req, items) => {
394
- // Called once with ALL items in bulk-upsert. Use for cross-item validation.
395
- // items: Array<{ main, secondaries? }>
396
- return []; // ValidationError[]
397
- },
398
-
399
- // HOOKS — side effects (runs after validation). All receive camelCase (schema field names).
400
- beforeInsert: async (db, req, record) => {
401
- // Mutate record before INSERT. Use camelCase (schema) field names — conversion is automatic.
402
- record.createdBy = req.user.id;
403
- },
404
- afterInsert: async (db, req, record, secondaryRecords) => {
405
- // Called after INSERT + secondaries. record is camelCase (input merged with generated PK).
406
- },
407
- beforeUpdate: async (db, req, fields) => {
408
- // Mutate fields before UPDATE. camelCase. PK is present for reference but excluded from UPDATE SET.
409
- fields.updatedBy = req.user.id;
410
- },
411
- afterUpdate: async (db, req, record, secondaryRecords, deletionRecords) => {
412
- // Called after UPDATE + secondaries + deletions, INSIDE the write transaction: throwing rolls back.
413
- },
414
- beforeDelete: async (db, req, id) => {
415
- // Throw to abort the single delete. For tenant-scoped tables runs only after ownership is verified.
416
- },
417
- afterDelete: async (db, req, id) => {
418
- // Called after a successful single delete (not on 404).
419
- },
420
- beforeBulkDelete: async (db, req, ids) => {
421
- // Called ONCE with all ids before a bulk delete. Throw to abort the whole batch.
422
- },
423
- afterBulkDelete: async (db, req, deletedIds) => {
424
- // Called ONCE with the ids ACTUALLY deleted (may be a subset of the requested ids).
425
- },
426
- afterRead: async (db, req, rows, ctx) => {
427
- // The read-side hook: turn the stored representation back into the API one.
428
- // Called ONCE per result set (batch), with camelCase rows mutated IN PLACE.
429
- // Runs for get, search, joinMultiple and joinLeft — including when THIS table's rows
430
- // arrive through a join declared on another table (ctx.source / ctx.alias say which).
431
- // Not called on joinGroup (aggregates, not rows) nor when the read returned nothing.
432
- for (const row of rows) row.ssn = decrypt(row.ssn);
433
- },
434
-
435
- // OPERATIONS — whitelist of auto-generated HTTP routes for this table.
436
- // Omitted = ALL operations exposed (default-open!). Does not affect programmatic sqlApi.*.
437
- // operations: ['search', 'get'],
438
-
439
- // AUTH — per-table request hooks
440
- onRequests: [
441
- async (request, reply) => {
442
- if (!request.user) return reply.status(401).send({ error: 'Unauthorized' });
443
- },
444
- ],
445
- });
446
- ```
447
-
448
- ### buildRelation signature
449
-
450
- ```typescript
451
- buildRelation(mainSchema, mainField, joinSchema, joinField, options?)
452
-
453
- // where options is fully optional:
454
- // {
455
- // alias?: string; // default = joinSchema.tableName. The key used in request/response
456
- // // payloads, secondaries/deletions, and dotted-notation orderBy/conditions.
457
- // selection?: string; // default '*'. Comma-separated columns: 'id, name, total'.
458
- // unique?: boolean; // default false.
459
- // // false → 1:N (child→main). Allowed in joinMustExist/joinMultiple/joinGroup.
460
- // // true → N:1 (parent→main). Allowed in joinLeft only.
461
- // fields?: string[]; // allowlist of the target's fields reachable through this relation.
462
- // // Read joins only. Must include joinField. Everything else is 400.
463
- // }
464
- ```
465
-
466
- - `mainField`: field(s) in main table (string or string[])
467
- - `joinField`: field in join table that joins to `mainField`
468
- - For 1:N (child→main): `joinField` is the FK on the child table → `mainField` is the PK on main
469
- - For N:1 (parent→main): `joinField` is the PK on the parent table → `mainField` is the FK on main
470
-
471
- **When to declare an explicit `alias`**: only when (a) you join the same table multiple times in the same `allowedReadJoins`/`allowedWriteJoins`, or (b) you want a friendlier name in the public API surface than the SQL table name (e.g. `'orders'` instead of `'customer_order'`). Otherwise omit it — the default is `joinSchema.tableName`.
472
-
473
- **⚠️ A relation in `allowedReadJoins` is a read grant on the target table.** The join runs inside the query of the *host* table's route, so the target's `onRequests` never fire and its `operations` whitelist does not apply — a table with `operations: []` is still fully readable through any relation pointing at it. What does cross a join: `readExclude`, `tenantScope`, and the schema the relation was declared with.
474
-
475
- To expose a table broadly but keep some of its columns narrow, give the relation a `fields` allowlist:
476
-
477
- ```typescript
478
- allowedReadJoins: [
479
- buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true, fields: ['id', 'name'] }),
480
- ],
481
- ```
482
-
483
- The relation is declared against a schema narrowed to that list, so every read surface validates against it — `selection`, `filters`, `conditions`, `orderBy`, aggregations, and the generated request/response schemas. A field outside the list is `400 Unknown field` everywhere, not just missing from the projection, and the default `'*'` selection is spelled out into the allowed columns rather than emitting a real `SELECT *`.
484
-
485
- Rules, all enforced at declaration time:
486
-
487
- - **Must include the join field** — it is what correlates the fetched rows with the main ones.
488
- - **Not allowed on `allowedWriteJoins`** — the allowlist restricts reading only; write paths resolve `upsertMap` by schema identity and must write every column the caller sent. Declare two relations if the same pair is joined for reading and for writing.
489
- - **Computed fields on the target resolve against the narrowed schema** — one that reads a field outside the list is a `400`, so a computed cannot be used to reach past the allowlist. One that stays inside keeps working.
490
- - **Fail-closed** — a column added to the target later is not reachable through the relation until it is added to `fields`.
491
-
492
- `fields` is not a replacement for `readExclude` (which hides a column from the owning table's own routes too). See [ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md) and [ADR 0011](./docs/adr/0011-join-fields-allowlist.md).
493
-
494
- **Choosing `unique`**: if `joinField` is the PK (or part of the composite PK) of `joinSchema`, the relation is N:1 — you almost certainly want `unique: true` so the alias is usable in `joinLeft`.
495
-
496
- **Owned child tables (translations, `*_info` details): use a writeJoin, not a standalone table.** A table that only exists as a child of a parent — e.g. `product_info` with composite PK `(product_id, lang)` — should be an `allowedWriteJoins` on the parent, not its own `DbTables` entry. The engine auto-fills the FK (`product_id`); add it to `upsertMap` (conflict key = the composite PK) to upsert children passing only their own fields. Avoids redundant endpoints/validators.
497
-
498
- ```typescript
499
- // on the parent (product) table:
500
- allowedWriteJoins: [
501
- buildRelation(SchemaProduct, 'id', SchemaProductInfo, 'productId', { alias: 'translations' }),
502
- ],
503
- upsertMap: buildUpsertRules(
504
- buildUpsertRule(SchemaProductInfo, ['productId', 'lang']), // composite conflict key
505
- ),
506
- // → PUT /rest/product { "main": {...}, "secondaries": { "translations": [{ "lang": "en", "name": "Bike" }] } }
507
- ```
508
-
509
- Expose a composite-PK table as a standalone CRUD table only when it stands on its own (M:N link tables, natural keys) — search, insert, update and bulk upsert fully support composite PKs; the by-single-id routes (get, delete, bulkDelete) are skipped for them.
510
-
511
- **Aliasing the same table twice**: declare two `buildRelation` entries with different `alias`. Example: a `session` table referencing `user` for both `createdBy` and `updatedBy`:
512
-
513
- ```typescript
514
- allowedReadJoins: [
515
- buildRelation(SchemaSession, 'createdBy', SchemaUser, 'id', { alias: 'creator', unique: true }),
516
- buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true }),
517
- ]
518
- ```
519
-
520
- If two entries in the same `allowedReadJoins`/`allowedWriteJoins` resolve to the same alias (explicit or implicit), `defineTable` throws at startup. So you cannot accidentally have two relations sharing an alias — the error tells you to disambiguate explicitly.
521
-
522
- The 1:N joins (`joinMustExist` / `joinMultiple` / `joinGroup`) work as: `SELECT {selection} FROM {childTable} WHERE {childFK} IN ({mainPK values})`. The N:1 join (`joinLeft`) adds `LEFT JOIN {parentTable} AS {alias} ON {alias}.{parentPK} = {main}.{mainFK}` to the main query, but only when the request actually needs it (filters/orderBy on parent fields). Otherwise a side query `SELECT FROM {parentTable} WHERE {parentPK} IN (distinct main FK values)` is used.
523
-
524
- **`joinLeft` limitation**: `extraFilters` declared via `extendedCondition` on the parent table are not applied inside `joinLeft.filters` (only schema fields). The other join families fully support extraFilters.
525
-
526
- ### extraFilters + extendedCondition
527
-
528
- For filters that don't map to real columns (e.g. full-text search `q`):
529
-
530
- ```typescript
531
- import {defineTable, exportTableInfo, Type, ConditionBuilder} from 'fastify-auto-sqlapi';
532
- import {SchemaCustomer as Schema} from '../schemas/SchemaCustomer';
533
-
534
- const extraFiltersValidation = Type.Object({
535
- q: Type.String(),
536
- });
537
-
538
- export const TableCustomer = defineTable({
539
- primary: 'id',
540
- ...exportTableInfo(
541
- Schema,
542
- extraFiltersValidation,
543
- // `filters` is auto-typed with keys from Schema.fields + extraFiltersValidation
102
+ // Extra filters: non-column search filters. Not applied as `col = value`, only via the callback. `filters` is typed with schema + extra keys. Not usable under joinLeft (400); use joinMustExist.
103
+ ...exportTableInfo(Schema, Type.Object({ q: Type.String(), dateFrom: Type.String(), dateTo: Type.String() }),
544
104
  (condition, filters) => {
545
105
  if (filters.q) {
546
106
  const or = new ConditionBuilder('OR');
@@ -548,40 +108,40 @@ export const TableCustomer = defineTable({
548
108
  or.isILike(Schema.col('email'), `%${filters.q}%`);
549
109
  condition.append(or);
550
110
  }
551
- }
552
- ),
553
- });
554
- ```
555
-
556
- `extraFilters` accepts either a `Type.Object({...})` or a plain `Record<string, TSchema>`. Extra filter fields appear in Swagger but are NOT auto-applied as `WHERE col = value` — they are handled exclusively by the `extendedCondition` callback. The `filters` parameter in the callback is fully typed with autocomplete for all schema fields + extra filter keys.
557
-
558
- **Not available on `joinLeft`.** `extendedCondition` writes its own column references, which cannot be qualified with the `LEFT JOIN` alias the parent gets there, so `buildLeftJoinClauses` handles schema and computed fields only. The generated body schema does not advertise the parent's `extraFilters` under `joinLeft.<alias>.filters`, and sending one is a `400` rather than a silent no-op. Use `joinMustExist` on the same relation when you need an extra filter on the parent.
111
+ // undefined values are skipped; a missing bound turns isBetween into >= / <=
112
+ condition.isBetween(Schema.col('orderDate'), filters.dateFrom, filters.dateTo);
113
+ }),
114
+ // Unknown filter keys (not a field, extra filter or computed) are 400, on main and every join, over HTTP and sqlApi alike.
559
115
 
560
- **Unknown filter keys are a `400`** (`Unknown filter field: <key>`), on the main table and on every join. A filter that does not match a schema field, an `extraFilters` key or a computed field is rejected instead of dropped: silently ignoring it would return *more* rows than the caller asked for. The check lives in the engine, so it applies to `sqlApi.search()` too, and it runs before any query — the outcome never depends on whether the main query matched rows.
116
+ defaultOrder: 'name ASC, id DESC', // fields mapped to columns; computed allowed; unknown tokens pass as raw SQL
117
+ distinctResults: true, // SELECT DISTINCT
118
+ operations: ['search', 'get'], // HTTP route whitelist. OMITTED = ALL ROUTES (default-open). Does not restrict sqlApi.* nor joins pointing at this table.
119
+ onRequests: [async (req, reply) => { // per-table hooks, after the global ones
120
+ if (req.user?.role !== 'admin') return reply.status(403).send({ error: 'Forbidden' });
121
+ }],
561
122
 
562
- ---
123
+ // --- field visibility ---
124
+ excludeFromCreation: ['id', 'createdAt'], // MUST list auto-increment PKs (CLI does it). Strips CLIENT input on insert only: beforeInsert and the FK auto-fill may still set it. Not a security tool: every field stays updatable (ADR 0004).
125
+ readExclude: ['passwordHash'], // never projected (search, get, default join selection); naming it in filters/conditions/orderBy/aggregations/selection → 400. Still writable. Static, applies to admins too. PK not allowed.
126
+ writeExclude: ['searchVector'], // dropped from insert/update/bulk bodies and in the engines; still readable. For trigger-owned columns; GENERATED columns need nothing. Runs after hooks. Not PK, not also in readExclude (that is excludeColumns).
127
+ schemaOverrides: { email: Type.String({ format: 'email' }) }, // stricter type for WRITE bodies only (responses and filters keep the generated type: stored rows predate the rule). Taken verbatim: no Optional → mandatory, no Nullable → null rejected.
563
128
 
564
- ## Computed Fields (extension system)
129
+ // --- joins (see Relations) ---
130
+ allowedReadJoins: [buildRelation(Schema, 'id', SchemaOrder, 'customerId', { alias: 'orders' })],
131
+ allowedWriteJoins: [buildRelation(Schema, 'id', SchemaOrder, 'customerId', { alias: 'orders' })],
565
132
 
566
- `computedFields` lets you declare **virtual fields** as SQL expressions on a per-table basis. Each computed becomes usable like a regular schema field across the search API: `filters` (equality), `conditions` (operators), `orderBy` (1-part), `computeMin/Max/Sum/Avg`, and (opt-in) in `selectComputed` for the main response. Same machinery serves JSON column extraction, derived strings, dialect-aware date/calendar bucketing — without growing the library case-by-case.
133
+ // --- upsert: INSERT becomes upsert for that schema, main or write-join child ---
134
+ upsertMap: buildUpsertRules(
135
+ buildUpsertRule(Schema, ['email']), // exactly the PK or one of the Schema's uniqueKeys, checked at startup (old Schemas: regenerate). Views: none.
136
+ buildUpsertRule(SchemaOrder, ['id']),
137
+ ),
138
+ // Each upserted row must carry its key (natural key non-null; PK whole, or absent = plain insert), no two rows of a request may share it → 400. A row must still be insertable (PG: NOT NULL without default is required even for an existing row). Another row's unique value = DB error, never a hijack.
567
139
 
568
- ```typescript
569
- import { defineTable, exportTableInfo, Type } from 'fastify-auto-sqlapi';
570
- import { SchemaCustomer as Schema } from '../schemas/SchemaCustomer';
140
+ // --- tenant (see Tenant scope) ---
141
+ tenantScope: { column: 'organization_id' }, // REAL column name
571
142
 
572
- export const TableCustomer = defineTable({
573
- primary: 'id',
574
- ...exportTableInfo(Schema),
143
+ // --- computed fields (see Computed fields) ---
575
144
  computedFields: {
576
- // JSON path extraction — dialect-aware (Postgres -> arrow, MySQL -> JSON_EXTRACT).
577
- statusFromMeta: ({ db, qiCol }) => ({
578
- expr: db.dialectName === 'postgres'
579
- ? `${qiCol('metadata')}->>'status'`
580
- : `JSON_UNQUOTE(JSON_EXTRACT(${qiCol('metadata')}, '$.status'))`,
581
- values: [],
582
- type: Type.String(),
583
- }),
584
- // Derived string column — dialect-aware concat.
585
145
  fullName: ({ db, qiCol }) => ({
586
146
  expr: db.dialectName === 'postgres'
587
147
  ? `${qiCol('firstName')} || ' ' || ${qiCol('lastName')}`
@@ -590,921 +150,251 @@ export const TableCustomer = defineTable({
590
150
  type: Type.String(),
591
151
  }),
592
152
  },
593
- });
594
- ```
595
-
596
- Client side, the computed name behaves like any other field:
597
-
598
- ```json
599
- {
600
- "filters": { "statusFromMeta": "active" },
601
- "conditions": [
602
- { "field": "fullName", "method": "isLike", "params": ["%Mario%"] }
603
- ],
604
- "orderBy": "fullName ASC",
605
- "selectComputed": ["fullName", "statusFromMeta"]
606
- }
607
- ```
608
-
609
- The values returned by `selectComputed` appear as extra fields on each `main[i]` row.
610
-
611
- ### `ComputedFieldFn` signature
612
-
613
- ```typescript
614
- type ComputedFieldFn = (ctx: {
615
- db: QueryClient; // dialect-aware (qi, ph, dialectName, dateTrunc, ...)
616
- qiCol(field: string, opts?: { qualifier?: string }): string;
617
- }) => {
618
- expr: string; // SQL fragment; mark each bound value with `?`
619
- values: unknown[]; // one entry per `?` marker in expr
620
- type: TSchema; // REQUIRED — used by Swagger and body validation
621
- };
622
- ```
623
-
624
- **Bound values use `?` markers.** The engine assigns the placeholder positions — never write `$1` or `db.ph(n)` yourself. Use `\?` for a literal question mark (PostgreSQL jsonb operator). A computed whose marker count does not match `values.length` is rejected with a descriptive error, because it would bind values the query never references:
625
-
626
- ```typescript
627
- bonus: ({ qiCol }) => ({
628
- expr: `CASE WHEN ${qiCol('role')} = ? THEN ${qiCol('salary')} ELSE 0 END`,
629
- values: ['admin'],
630
- type: Type.Number(),
631
- }),
632
- ```
633
-
634
- `qiCol(field)` returns a properly-quoted column reference, optionally prefixed by an alias qualifier — the engine passes the alias automatically when the computed is invoked inside a `joinLeft` LEFT JOIN, so the same function works both inline on the main query and as a parent-side column.
635
-
636
- ### Validation & startup checks
637
-
638
- `defineTable` throws synchronously if a computed name collides with a schema field or an `extraFilters` key on the same table. The error message tells you which name to change.
639
-
640
- ### Side queries (`joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`)
641
-
642
- Computed fields are **per-table**: a side query operating on a join target reads `joinTableConf.computedFields`, not the main table's. So if you need `statusFromMeta` on `joinLeft.user.filters`, declare it on `TableUser`, not on `TableSession`.
643
-
644
- For `joinLeft` specifically, the computed expr is automatically alias-prefixed by `qiCol` (the engine forwards the alias). For the other three families the side query is a separate SELECT, so plain column references suffice.
645
-
646
- ### Limitations (first round, by design)
647
-
648
- - **Bound `values` in computed expressions** work anywhere the expression lands in the `WHERE` clause or in `ORDER BY`: `filters` and `conditions` (main, `joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`) and `orderBy`. The ConditionBuilder assigns the placeholder positions there, so no coordination is needed from the caller.
649
- - They are still rejected with 400 on `selectComputed`, `computeMin/Max/Sum/Avg`, `joinGroup.aggregations.by` and `defaultOrder`. Those expressions are emitted in the `SELECT` / `GROUP BY` list, which precedes the `WHERE` values in the parameter order — there is no correct position for them there. Most use cases (JSON extraction, concat, dateTrunc, simple ops) need no bound values at all.
650
- - **Computed CAN be used in `joinGroup.aggregations.by`** (just pass the computed name as a string). Bound `values` are not supported in this position (rejected with 400 — it is emitted in the `SELECT`/`GROUP BY` list). The existing FK-correlation rule for `orderBy <alias>.<fn>.<field>` still applies: 3-part aggregation orderBy on a `by` that isn't the correlation FK is rejected — by definition a computed-by produces multiple groups per main row, so this is never valid.
651
- - **Computed cannot be used as aggregation function** (`sum`/`min`/`max`/...). The values inside `aggregations.sum: ['<name>']` must be schema field names. To aggregate on a derived expression, declare the derivation as a computed and pass the computed name as the field, BUT only via `computeMin/Max/Sum/Avg` (top-level main aggregates), not the joinGroup ones.
652
- - **No chained computed** (a computed referencing another computed). Flat-only.
653
- - **Read-only**. Computed fields are not usable in insert/update bodies — the consumer's `expr` is for SELECT/WHERE/ORDER BY, never for writes.
654
- - **Same expression evaluated multiple times** when used in WHERE + ORDER BY + SELECT. Cheap exprs are fine; for expensive ones, query planner CSE often helps.
655
153
 
656
- ---
657
-
658
- ## ConditionBuilder API
659
-
660
- Used in `extendedCondition` callbacks, hooks, and exposed via the `conditions` field in the search API (methods and params arity in [AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md)).
661
-
662
- ```typescript
663
- const cb = new ConditionBuilder('AND'); // or 'OR'
664
- cb.isEqual('column', value);
665
- cb.isNotEqual('column', value);
666
- cb.isGreater('column', value);
667
- cb.isGreaterOrEqual('column', value);
668
- cb.isLess('column', value);
669
- cb.isLessOrEqual('column', value);
670
- cb.isLike('column', '%value%');
671
- cb.isILike('column', '%value%'); // case-insensitive LIKE
672
- cb.isBetween('column', from, to);
673
- cb.isIn('column', [val1, val2]);
674
- cb.isNotIn('column', [val1, val2]);
675
- cb.isNull('column', true);
676
- cb.isNotNull('column', true);
677
- cb.raw('column::text = $1', [value]); // raw SQL with params (backend only, NOT exposed in API)
678
- cb.append(otherConditionBuilder); // nest conditions (backend only, NOT exposed in API)
679
- ```
680
-
681
- All field-based methods (except `raw` and `append`) are available in the search API `conditions` array. New methods added to ConditionBuilder are automatically available after adding them to the whitelist.
682
-
683
- All values are parameterized (`$1, $2, ...`), never interpolated.
684
-
685
- ---
686
-
687
- ## SqlApi — Programmatic High-Level API
688
-
689
- Use `SqlApi` to perform CRUD operations from custom routes with the same capabilities as the auto-generated endpoints. The internal auto-generated routes also use `SqlApi`, guaranteeing a single code path.
690
-
691
- It takes and returns **camelCase schema field names**, exactly like the HTTP bodies documented in
692
- [AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md) — `sqlApi.search('order', { filters: { customerId: 3 } })`,
693
- never `customer_id`. Hooks, validation, computed fields and tenant scoping all run, so this is the
694
- right entry point for application code. Drop to [`QueryClient`](#queryclient-api) only for SQL that
695
- has no equivalent here — it is the layer underneath, with none of that behavior and with real DB
696
- column names.
697
-
698
- ### Using `app.sqlApi` (recommended)
699
-
700
- After registering the plugin, `app.sqlApi` is available everywhere — no extra configuration needed:
701
-
702
- ```typescript
703
- await app.register(fastifyAutoSqlApi, {
704
- DbTables: dbTables,
705
- dialect: 'mysql',
706
- swagger: true,
707
- prefix: '/auto',
708
- });
709
-
710
- // app.sqlApi is available in any route — even outside the plugin scope
711
- app.get('/billing/:customerId', async (request) => {
712
- const customerId = Number(request.params.customerId);
713
- return app.sqlApi.search('subscription', {
714
- filters: { customerId, status: 'active' },
715
- joinMustExist: { orders: { filters: { status: 'pending' } } },
716
- paginator: { page: 1, itemsPerPage: 50 },
717
- }, request);
718
- });
719
- ```
720
-
721
- `app.sqlApi` inherits all configuration from the plugin registration (dialect, DbTables, tenant, debug). No need to pass them again.
722
-
723
- ### Using `createSqlApi` (standalone — for background jobs or without the plugin)
724
-
725
- When you need a `SqlApi` instance without registering the full plugin (e.g. background jobs, scripts, tests):
726
-
727
- ```typescript
728
- import { createSqlApi, mysqlQueryable } from 'fastify-auto-sqlapi';
729
-
730
- // Option 1: pass a raw pool — SqlApi creates the QueryClient internally
731
- const sqlApi = createSqlApi(mysqlQueryable(pool), dbTables, { dialect: 'mysql' });
732
-
733
- // Option 2: pass a pre-built QueryClient
734
- import { createQueryClient } from 'fastify-auto-sqlapi';
735
- const db = createQueryClient(mysqlQueryable(pool), 'mysql');
736
- const sqlApi = createSqlApi(db, dbTables, { dialect: 'mysql' });
737
- ```
738
-
739
- **Important**: when using `createSqlApi` standalone, always pass `dialect` in the options. It configures both the QueryClient (identifier quoting, placeholders) and the ConditionBuilder (used by filters). Without it, defaults to PostgreSQL syntax.
740
-
741
- ### Available methods
742
-
743
- ```typescript
744
- // Search — full filter, join, pagination, aggregation support
745
- // orderBy supports two dotted notations:
746
- // - 3-part `<alias>.<fn>.<field>` for joinGroup aggregations (must be declared in the request body)
747
- // - 2-part `<alias>.<field>` for joinLeft parent fields
748
- // See AGENTS_FRONTEND.md and BREAKING_CHANGES.md for the full reference.
749
- sqlApi.search(tableName, {
750
- filters?, conditions?,
751
- joinMustExist?, joinMultiple?, joinGroup?, joinLeft?,
752
- orderBy?, paginator?, computeMin?, computeMax?, computeSum?, computeAvg?,
753
- }, request?): Promise<SearchResult>
754
-
755
- // Get single record by PK
756
- sqlApi.get(tableName, id, request?): Promise<GetResult>
757
-
758
- // Insert — with optional secondaries, hooks, tenant
759
- sqlApi.insert(tableName, { record, secondaries? }, request?): Promise<InsertResult>
760
-
761
- // Update — with optional secondaries, deletions, hooks, tenant
762
- sqlApi.update(tableName, { record, secondaries?, deletions? }, request?): Promise<UpdateResult>
763
-
764
- // Delete by PK
765
- sqlApi.delete(tableName, id, request?): Promise<DeleteResult>
766
-
767
- // Bulk upsert
768
- sqlApi.bulkUpsert(tableName, items, request?): Promise<BulkUpsertResult[]>
769
-
770
- // Bulk delete
771
- sqlApi.bulkDelete(tableName, ids, request?): Promise<BulkDeleteResult[]>
772
- ```
773
-
774
- The `request` parameter is optional. Pass it when you need tenant resolution or hooks (which receive `req`). Without it, tenant filtering is skipped and hooks receive `undefined` as the request.
775
-
776
- ---
777
-
778
- ## QueryClient API
779
-
780
- The SQL layer **underneath** [`SqlApi`](#sqlapi--programmatic-high-level-api): it executes what you
781
- give it and nothing else — no hooks, no validation, no computed fields, no tenant scoping. Reach for
782
- it only when the operation has no `sqlApi` equivalent; anything expressed as CRUD belongs there.
783
-
784
- It speaks the **database's own names**: real table names (`Schema.tableName`, not the schema
785
- variable) and real column names — get them from `Schema.col('customerId')` rather than assuming a
786
- convention, since the columns may be snake_case or already camelCase. Passing a schema field name
787
- the mapping would have translated produces a SQL error at runtime, not a validation error.
788
-
789
- Available in hooks via the `db` parameter:
790
-
791
- ```typescript
792
- db.insert(tableName, record, pkCol); // INSERT, returns PK row
793
- db.insertOrUpdate(tableName, record, conflictKeys, pkCol); // INSERT ON CONFLICT, returns PK row
794
- db.bulkInsert(tableName, records, pkCol, chunkSize?); // Multi-row INSERT, returns PK rows
795
- db.bulkInsertOrUpdate(tableName, records, conflictKeys, pkCol, chunkSize?); // Multi-row UPSERT, returns PK rows
796
- db.update(tableName, record, where, extraCondition?); // UPDATE, returns affectedRows (number)
797
- db.delete(tableName, where); // DELETE, returns affectedRows (number)
798
- db.select({ tableName, columns?, where, values, limit?, orderBy?, joins?, distinct? }); // SELECT, returns rows
799
- db.query(sql, values); // Raw query
800
- db.expression(value); // Raw SQL expression (not parameterized)
801
- ```
802
-
803
- `pkCol` can be a string (`'id'`) or an array (`['agent_id', 'team_id']`) for composite PKs. PostgreSQL/MariaDB use `RETURNING`, MySQL uses `insertId`.
804
-
805
- Record keys are real column names (`Schema.col('field')`). Values are parameterized.
806
-
807
- ---
808
-
809
- ## Swagger
810
-
811
- ```typescript
812
- import { setupSwagger } from 'fastify-auto-sqlapi';
813
-
814
- // Basic (UI at /documentation)
815
- await setupSwagger(instance, { swagger: true });
816
-
817
- // Custom
818
- await setupSwagger(instance, {
819
- swagger: {
820
- title: 'My API',
821
- description: 'Auto-generated CRUD API',
822
- version: '1.0.0',
823
- routePrefix: '/docs',
154
+ // --- validation: after schema validation, before hooks and SQL. Any error → 400, nothing written ---
155
+ validate: async (db, req, main, secondaries) => { // camelCase, secondaries keyed by alias
156
+ const errors: ValidationError[] = []; // [field, code] or [field, code, message]
157
+ if (!main.name) errors.push(['name', 'required']);
158
+ const orders = secondaries?.orders ?? [];
159
+ if (orders.some((o) => o.total < 0)) errors.push(['orders[0].total', 'min', 'must be >= 0']);
160
+ return errors;
824
161
  },
825
- });
826
- ```
827
-
828
- Requires `@fastify/swagger` and `@fastify/swagger-ui` as peer deps. If not installed, logs a warning and continues.
829
-
830
- ---
831
-
832
- ## Multi-Tenant Filtering
833
-
834
- Automatic row-level isolation per tenant on all CRUD operations. Zero code in route handlers — just configure and go.
835
-
836
- ### Setup
837
-
838
- ```typescript
839
- import { fastifyAutoSqlApi } from 'fastify-auto-sqlapi';
840
-
841
- await app.register(fastifyAutoSqlApi, {
842
- DbTables: dbTables,
843
- // Return tenant ID(s) from request. null/undefined = admin (no filter).
844
- getTenantId: (request) => {
845
- const orgId = request.user?.organizationId;
846
- return orgId ?? null;
162
+ validateBulk: async (db, req, items) => { // bulk upsert: called once with [{ main, secondaries? }]; when defined, per-item validate is skipped in bulk
163
+ const codes = items.map((i) => i.main.code);
164
+ return new Set(codes).size === codes.length ? [] : [['code', 'batch_unique']];
847
165
  },
848
- });
849
- ```
850
-
851
- Every column named in a `tenantScope` — `column`, the entries of `anyOf`, `through.localField`
852
- and `through.foreignField` — is a **real DB column name**, not a camelCase schema field. This is
853
- the one key of `defineTable` where the convention flips, and it is deliberate: a scope is applied
854
- to the SQL, *after* the payload has been converted, and `through.foreignField` belongs to a parent
855
- table that may have no config, no `colMap`, or no generated schema at all. Same category as `db.*`
856
- and `extendedCondition` — names that go straight to the database.
857
-
858
- Write them as the table declares them (`organization_id` below, but `organizationId` on a
859
- camelCase table); clients keep sending the camelCase field name either way, including when
860
- anchoring an `anyOf` write.
861
-
862
- `defineTable` catches the mistake this exception invites: naming the schema **field** whose real
863
- column is something else (`organizationId` where the column is `organization_id`) throws at
864
- startup, on all four positions, checking `through.foreignField` against the parent schema. It
865
- cannot check that a column *exists* — a hand-written schema may legitimately omit it — but it can
866
- tell you that you named a field. Without it the mistake surfaces as
867
- `column "organizationId" does not exist` on every request to the table: an opaque `500` that fails
868
- closed and says nothing. On a camelCase database the check raises nothing, since `col()` is the
869
- identity there.
870
-
871
- ### Direct tenant (column on the table itself)
872
-
873
- ```typescript
874
- const TableCustomer = defineTable({
875
- primary: 'id',
876
- ...exportTableInfo(SchemaCustomer),
877
- tenantScope: { column: 'organization_id' },
878
- });
879
- ```
880
166
 
881
- Behavior:
882
- - **Read** (search, get, delete, bulk-delete): adds `AND organization_id IN ($N)` to WHERE
883
- - **Insert**: auto-injects `organization_id` if single tenant; validates if already present (403 if mismatch); 400 if multi-tenant without explicit value. The insert body (main and secondaries items) offers the column as **optional**, whatever the generated schema says: a single-tenant client omits it, a multi-tenant client names it. An admin (`getTenantId` → `null`) who omits it reaches the database's own `NOT NULL`, like any other column.
884
- - **Update**: strips `organization_id` from SET (can't change tenant), adds to WHERE condition
885
- - **Bulk upsert**: auto-injects on all records
886
- - **Upsert conflict guard** (`upsertMap` set): before the `ON CONFLICT DO UPDATE`, a probe rejects (403) any incoming conflict key that matches a row owned by another tenant — an upsert cannot overwrite or re-assign a foreign tenant's row.
887
-
888
- ### Indirect tenant (via JOIN to parent table)
889
-
890
- ```typescript
891
- const TableOrder = defineTable({
892
- primary: 'id',
893
- ...exportTableInfo(SchemaOrder),
894
- tenantScope: {
895
- column: 'organization_id',
896
- through: {
897
- schema: SchemaCustomer, // parent table schema
898
- localField: 'customer_id', // FK on this table
899
- foreignField: 'id', // PK on parent table
900
- },
167
+ // --- hooks: camelCase records, mutations reach the SQL ---
168
+ beforeInsert: async (db, req, record) => {
169
+ record.createdBy = req.user.id;
170
+ record.createdAt = db.expression('NOW()'); // raw SQL value
171
+ },
172
+ beforeUpdate: async (db, req, fields) => { // PK present but not in SET
173
+ if (!req.user.isAdmin) delete fields.isAdmin; // field-level rules: silent strip here, or 400 in validate
174
+ fields.updatedAt = db.expression('NOW()');
175
+ },
176
+ afterInsert: async (db, req, record, secondaryRecords) => {}, // record = input + generated PK
177
+ afterUpdate: async (db, req, record, secondaryRecords, deletionRecords) => {}, // inside the transaction: throw = rollback
178
+ beforeDelete: async (db, req, id) => {}, // throw aborts; runs after tenant ownership is verified
179
+ afterDelete: async (db, req, id) => {}, // not called on 404
180
+ beforeBulkDelete: async (db, req, ids) => {}, // once; throw aborts the whole batch
181
+ afterBulkDelete: async (db, req, deletedIds) => {}, // once, only the ids actually deleted
182
+ afterRead: async (db, req, rows, ctx) => { // see afterRead
183
+ for (const row of rows) if (row.ssn != null) row.ssn = decrypt(row.ssn);
901
184
  },
902
185
  });
903
186
  ```
904
187
 
905
- Behavior:
906
- - **Read**: INNER JOIN to parent table + WHERE on parent's tenant column
907
- - **Insert/Bulk upsert**: validates FK references belong to tenant (1 batch query)
908
- - **Update**: pre-check via SELECT with INNER JOIN (404 if not found). If the payload also changes the through-FK (`localField`), the new value is re-validated against the tenant — moving a record into another tenant's scope is rejected (403).
909
- - **Delete**: subquery `DELETE WHERE pk IN (SELECT ... INNER JOIN ... WHERE tenant IN (...))`
188
+ Nullable columns are generated as `Type.Optional(Nullable(T))`, i.e. `{ type: ['integer', 'null'] }`. Do not rewrite this as `Type.Union([T, Type.Null()])`, which Ajv `coerceTypes` corrupts (`null`→`0`, `0`→`null`), or as a bare `Type.Optional(T)`, where NULL serializes as `0`/`""`. Response schemas are `Type.Partial`. A `null` in `filters` means `IS NULL`.
189
+
190
+ Write bodies are closed (`additionalProperties: false`), so a column that is not in the Schema can never be written (no mass assignment). With Fastify's default `removeAdditional: true`, unknown properties are stripped silently. `Fastify({ ajv: { customOptions: { removeAdditional: false } } })` makes them a 400.
910
191
 
911
- ### Shared tenant (`anyOf`: several owner columns)
192
+ Errors the plugin raises itself are 4xx with client-safe messages. Anything else (a constraint violation, a hook throwing without `statusCode`) returns a fixed `{statusCode:500, error, message:"Internal Server Error", requestId}`: the driver error is logged and set as `err.cause`, and no status is remapped. To map it, add a `setErrorHandler` that checks `err.cause.code === '23505'` and returns 409. Errors thrown by `onRequests` are unaffected.
912
193
 
913
- For a row owned by two parties and visible to either — a message (`sender_id` / `recipient_id`),
914
- a transfer (`from_account_id` / `to_account_id`), a shift swap:
194
+ ## Relations
915
195
 
916
- ```typescript
917
- const TableShiftSwapRequest = defineTable({
918
- primary: 'id',
919
- ...exportTableInfo(SchemaShiftSwapRequest),
920
- tenantScope: { anyOf: ['requester_agent_id', 'target_agent_id'] },
196
+ ```ts
197
+ buildRelation(mainSchema, mainField, joinSchema, joinField, {
198
+ alias, // default joinSchema.tableName. Key in requests, responses, secondaries, deletions, dotted notation. Set it only to join one table twice or for a friendlier name. Duplicate alias → throws.
199
+ selection, // default '*'. 'id, name, total'
200
+ unique, // false: 1:N child→main (joinField = child FK) → joinMustExist / joinMultiple / joinGroup. true: N:1 parent→main (joinField = parent PK) → joinLeft. joinField is the target's PK → use true.
201
+ fields, // read-only allowlist of the target's fields. Must include joinField. Not on write joins.
921
202
  });
203
+
204
+ allowedReadJoins: [
205
+ buildRelation(SchemaSession, 'createdBy', SchemaUser, 'id', { alias: 'creator', unique: true }),
206
+ buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true, fields: ['id', 'name'] }),
207
+ ],
922
208
  ```
923
209
 
924
- Behavior:
925
- - **Read** (search, get, delete, bulk-delete): adds
926
- `AND (requester_agent_id IN ($N) OR target_agent_id IN ($M))`. A `NULL` column does not match
927
- and does not stop the other column from matching. A row with every listed column `NULL` is
928
- visible to nobody but an admin.
929
- - **Joins**: the predicate crosses every join family — `joinMultiple`, `joinGroup`,
930
- `joinMustExist`, and `joinLeft` (in the `ON` clause, alias-qualified, so `LEFT` semantics
931
- survive) — exactly as a single-column scope does, including from a host table that declares no
932
- scope of its own.
933
- - **Insert**: no auto-injection. The payload must **anchor** the row: at least one listed column
934
- present and holding a tenant id → `400` if none is present, `403` if present but all foreign.
935
- The other parties are stored exactly as sent. A multi-id caller is never ambiguous here,
936
- because nothing is being guessed. Every listed column is **optional** in the insert body, so
937
- the anchor rule is the runtime's to enforce, not the schema's.
938
- - **Update**: strips **every** listed column from the `SET` — neither party can be re-assigned —
939
- and adds the OR predicate to the `WHERE`. A row outside the scope answers `404`.
940
- - **Bulk upsert**: the anchor rule applies to every item (so an upsert must name a party even
941
- when it only means to update), and the listed columns are excluded from the `DO UPDATE SET`,
942
- so an upsert onto an existing row cannot take over the other party's slot. The conflict guard
943
- rejects (`403`) a conflict key matching a row visible to neither party — including a row whose
944
- parties are `NULL`.
945
- - **Secondaries**: a table with an `anyOf` scope written through another table's
946
- `allowedWriteJoins` follows the same anchor rule. The parent's FK is auto-filled but is not an
947
- owner, so the child payload must still name a party.
948
-
949
- Not combinable with `column` or `through` — `defineTable` throws at startup. An `anyOf` entry
950
- reached through a parent FK is not supported; declare the scope on the parent instead.
951
-
952
- ### Multi-tenant users
953
-
954
- ```typescript
955
- getTenantId: (request) => {
956
- // Return array for users managing multiple tenants
957
- return request.user?.organizationIds ?? null; // e.g. [1, 2, 3]
210
+ - **A read join is a read grant on the target.** It runs inside the host's route: the target's `onRequests` and `operations` do not apply, so a table with `operations: []` is still readable through it. The target's `readExclude`, `tenantScope`, `afterRead` and the relation's schema still apply. `fields` narrows that schema, fail-closed: every other field is a 400 in selection, filters, conditions, orderBy and aggregations, and so is a computed field reaching past it. A column added later is not reachable until listed. Use `readExclude` instead to hide a column from the table's own routes too. (ADR 0010, 0011)
211
+ - joinLeft adds a real `LEFT JOIN` only when filters or orderBy need the parent; otherwise it runs a side query.
212
+ - **Owned children** (translations, `*_info`, composite PK like `(product_id, lang)`): make them a write join on the parent, not a `dbTables` entry. The child FK is auto-filled, and an `upsertMap` rule on the child key lets clients send only the child's own fields: `PUT /rest/product { main, secondaries: { translations: [{ lang: 'en', name: 'Bike' }] } }`. Expose a composite-PK table standalone only when it stands on its own (M:N links).
213
+ - **What a write join runs on the child**: its `excludeFromCreation`, `tenantScope` and `beforeInsert`, then the FK auto-fill, which always wins. Not its `validate` or `afterInsert` (ADR 0014).
214
+
215
+ ## Computed fields
216
+
217
+ A computed field is a SQL expression usable like a field in `filters`, `conditions`, `orderBy`, `computeMin/Max/Sum/Avg` and `joinGroup.aggregations.by`. It is projected only when requested through `selectComputed`.
218
+
219
+ ```ts
220
+ computedFields: {
221
+ statusFromMeta: ({ db, qiCol }) => ({ // db: QueryClient (dialectName, dateTrunc, ...)
222
+ expr: db.dialectName === 'postgres'
223
+ ? `${qiCol('metadata')}->>'status'`
224
+ : `JSON_UNQUOTE(JSON_EXTRACT(${qiCol('metadata')}, '$.status'))`,
225
+ values: [], // required: one per `?` marker (mismatch throws)
226
+ type: Type.String(), // required: Swagger + body validation
227
+ }),
228
+ bonus: ({ qiCol }) => ({
229
+ expr: `CASE WHEN ${qiCol('role')} = ? THEN ${qiCol('salary')} ELSE 0 END`, // `?` markers, never $1; `\?` = literal ?
230
+ values: ['admin'],
231
+ type: Type.Number(),
232
+ }),
233
+ displayName: ({ qiCol }) => ({ // translated jsonb column {"en_US": "Bike", ...}
234
+ expr: `${qiCol('name')}->>'${LANG}'`,
235
+ values: [],
236
+ type: Type.String(),
237
+ }),
958
238
  },
959
239
  ```
960
240
 
961
- Uses `IN ($N, $M, ...)` for all WHERE clauses (same as single tenant, just with more values).
962
-
963
- An **empty array** is a caller who belongs to no tenant, not an admin: every request that
964
- reaches a scoped table (directly or through a relation) is refused with `403` before any SQL
965
- runs. `0` and `''` are ordinary tenant ids and filter like any other value.
241
+ - Computed fields are per table: a join's side query uses the target table's computed fields. On joinLeft, `qiCol` adds the alias automatically.
242
+ - A name that collides with a schema field or an extra filter throws in `defineTable`.
243
+ - Bound `values` work in WHERE and ORDER BY positions. They are rejected (400) in `selectComputed`, `compute*`, `aggregations.by` and `defaultOrder`.
244
+ - Limits: read-only (not writable), no computed field referencing another, not usable as a joinGroup aggregation field (`sum: [...]` takes schema fields). The expression is evaluated once per use.
966
245
 
967
- ### Admin bypass
246
+ ## afterRead
968
247
 
969
- When `getTenantId` returns `null` or `undefined`, no filtering is applied (full access). Only
970
- these two values bypass; `[]`, `0` and `''` do not.
248
+ The read-side counterpart of `beforeInsert`/`beforeUpdate`: decrypt, unpack, rescale. Declare it on the table that **owns** the column. It then runs wherever those rows surface: `ctx.source` is `get | search | joinMultiple | joinLeft`, and `ctx.alias` names the relation on joins. It is called once per result set and mutates the rows in place.
971
249
 
972
- ### Tables without tenant
250
+ - It does not run on joinGroup, compute*, or an empty result. It cannot add or remove rows (the COUNT has already run).
251
+ - `filters`, `orderBy` and `compute*` act on the **stored** value, so an encrypted column is searched as ciphertext. With a deterministic cipher, encrypt the search value in `extendedCondition`. With a randomized cipher, the column cannot be searched.
252
+ - It changes values, not types: the response is serialized with the generated schema (an object in a `text` column becomes `"[object Object]"`). If the stored type and the API type differ, write a custom route.
253
+ - For encryption at rest, pair it with `beforeInsert` + `beforeUpdate` (as in the defineTable example). That covers own routes, joins, and writes as a secondary.
973
254
 
974
- Tables without `tenantScope` are unaffected — no filtering regardless of `getTenantId` result.
255
+ ## Tenant scope
975
256
 
976
- ### Error codes
257
+ `getTenantId(req)` returns one of:
977
258
 
978
- - **403** — Record doesn't belong to tenant, explicit tenant value doesn't match, or
979
- `getTenantId` returned an empty array (a caller with no tenant at all)
980
- - **400** — Multi-tenant user on insert without explicit tenant value (ambiguous), or an
981
- `anyOf` write naming no owner column at all
982
- - **404** — Update with indirect tenant, record not found for this tenant; get/update/delete of
983
- an `anyOf`-scoped row outside the scope
259
+ - an id → `IN (id)`
260
+ - an array → `IN (...)`
261
+ - `null` / `undefined` → admin, no filter
262
+ - `[]` → no tenant at all: 403 on any scoped table, before SQL runs
984
263
 
985
- ---
264
+ `0` and `''` are ordinary ids. The tenant belongs to the request, not to the table: a scoped table is also filtered when it is a join target or a secondary of an unscoped host. So `getTenantId` must tolerate an unauthenticated request (`req.user?.x ?? null`). Tables without `tenantScope` are never filtered.
986
265
 
987
- ## Views
988
-
989
- A view is a **table config** like any other — same `defineTable`, same `Table*.ts` file, same
990
- routes. There is no `View*` anything: searching, filtering, ordering, pagination, joins and
991
- computed fields all work through the same code path as a base table. Materialized views are
992
- included, PostgreSQL ones read from `pg_catalog` since `information_schema` does not describe
993
- them.
994
-
995
- What differs is what the generator can read from the database, and it is worth knowing before
996
- you accept the file it writes.
997
-
998
- **The primary key is not declared.** A view carries no `PRIMARY KEY` constraint, so
999
- `sqlapi-generate-schema` has nothing to report and the table generator **will not guess one
1000
- from the column types** — on `SELECT status, count(*) AS order_count ... GROUP BY status` that
1001
- would take the count as the key, and `get/:id`, `update` and `delete` would then address rows
1002
- by it. Instead:
1003
-
1004
- - the view has an `id` column → it is used, and the file says the key was *inferred, not read
1005
- from the database*. Check that it is really unique in the view before exposing
1006
- `get`/`update`/`delete`.
1007
- - no plausible column → `primary: 'TODO_pick_a_unique_column'` plus `operations: ['search']`
1008
- and a real `defaultOrder`. **This config works as generated**: search reads `defaultOrder`,
1009
- never the primary key. The TODO is what you replace to widen `operations`, not a defect.
1010
-
1011
- `defineTable` enforces that: a `primary` naming a field the schema does not have throws **at
1012
- startup**, unless the table exposes only `search` and declares its own `defaultOrder`.
1013
-
1014
- **Writes start off, but are not forbidden.** A view's generated config is
1015
- `operations: ['search', 'get']`. An aggregating view rejects every INSERT and UPDATE at the
1016
- database (`cannot insert into view ...`, a raw `500`), while a view simple enough for the engine
1017
- to make updatable accepts them on both PostgreSQL and MySQL — using one as a projection or
1018
- permission layer is a supported pattern. The read-only start is the *generated template*, not a
1019
- runtime rule: remove or widen `operations` and the write routes come back.
1020
-
1021
- **Nullability differs between engines** for the same view: PostgreSQL reports every view column
1022
- as nullable, MySQL reports the real nullability of the underlying expression. The generated
1023
- Schema therefore differs by dialect for one and the same view. It affects write bodies only, and
1024
- a view is not usually written to.
1025
-
1026
- `sqlapi-generate-schema` prints the views it found and both assumptions above — read that
1027
- output, it is the part that does not depend on anyone opening the generated file. See
1028
- [ADR 0016](./docs/adr/0016-a-view-is-a-table-config.md).
1029
-
1030
- ---
1031
-
1032
- ## Key Conventions
1033
-
1034
- - **camelCase in API, any case in DB**: all request/response fields are camelCase. The plugin converts via `col()` and `colMap`. For snake_case DB columns (default), conversion is automatic. For camelCase DB columns (e.g. betterauth), the CLI generates a `colMap` that preserves the original column names — no conversion needed. Manual schemas without `colMap` fall back to `toUnderscore()`.
1035
- - **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
1036
- - **Nullable columns use `Nullable(T)` (type-array form)**: the generator emits `Type.Optional(Nullable(T))` for nullable columns — `Nullable` (exported by the package) produces `{ type: ['integer', 'null'] }`. Do NOT replace it with `Type.Union([T, Type.Null()])`: under Fastify's default Ajv `coerceTypes` a union corrupts values through its branches (`null` → `0` with the Null branch last, `0`/`""` → `null` with it first), while the type array validates and serializes NULL correctly. A bare `Type.Optional(T)` is also wrong: NULL serializes as `0`/`""`. In `filters`, an explicit `null` on a schema field builds `WHERE col IS NULL`.
1037
- - **`excludeFromCreation`**: **IMPORTANT** — auto-increment PKs (e.g. `id` serial/auto_increment) MUST be listed here, otherwise INSERT will try to send them and fail. The CLI auto-detects this and adds it by default. Also useful for `createdAt`/`updatedAt` columns managed by DB defaults or hooks. It strips **client-supplied** values only: the payload is sanitized before `beforeInsert` runs, so a value the hook assigns to an excluded field (e.g. a server-generated TEXT id) DOES reach the INSERT. Same for the engine's FK auto-fill on secondaries — an excluded FK column does not suppress it. It is an **ergonomics tool for creation, NOT a field-level security mechanism**: it does not apply to updates — every Schema field is updatable by default, by design. Field-level update rules (`isAdmin`, roles, owner/tenant columns, state fields) are product logic: enforce them in `beforeUpdate` (silent strip) or `validate` (loud 400), or move the privileged transition to a dedicated endpoint and keep it off the auto routes via `operations`.
1038
- - **`readExclude`**: fields hidden from every read — not projected by search/get, omitted from read response schemas and from this table's default (`*`) join selection when it is a join target. Referencing one from `filters`, `conditions`, `orderBy`, `joinGroup` aggregations or an explicit join `selection` returns 400: hiding a field from output while allowing it to be filtered would leak its value by bisection. **Writes are unaffected** — insert/update/bulk still accept the field, so a column can be writable but never readable (password hash, access token). Primary keys cannot be excluded. Complementary to `excludeColumns` in `sqlapi.config.ts`, which removes the column from the Schema, hence from reads *and* writes. It is **per table and static**: a column hidden here is hidden from every caller, including admins. To keep a column readable on the table's own routes but not through a relation, trim the Schema the *relation* is declared with instead — see the read-grant warning under [buildRelation](#buildrelation-signature).
1039
- - **`writeExclude`**: the write-side counterpart of `readExclude` — fields removed from the insert, update and bulk-upsert bodies (main *and* secondaries) and dropped again inside the engines, which `sqlApi.*` reaches without those schemas. Reads are untouched: the field is still projected, filterable and orderable. It exists for the columns the database refuses to be told about. **A `GENERATED ALWAYS AS (<expr>)` column is already covered without any config**: the CLI records it in the Schema's `generatedFields`, and both engines reject a statement that so much as names such a column, so offering it as writable could only turn a client mistake into a `500`. The `writeExclude` key is for the rest — a column a trigger owns, one a migration is about to drop. It is **static and applies to everyone**, admins included; a rule that depends on *who* is asking is product logic and belongs in `beforeUpdate` (silent strip) or `validate` (loud 400), per [ADR 0004](./docs/adr/0004-updates-always-open.md). Not to be confused with `excludeFromCreation`, which whitelists **client input on insert only** and deliberately lets a hook assign the field afterwards — `writeExclude` runs **after** the hooks, because the column refuses the value whoever assigned it. `defineTable` rejects a field that is not in the schema, the primary key (the update body identifies the row with it) and a field that is also in `readExclude` (neither readable nor writable means: `excludeColumns` in `sqlapi.config.ts`). See [ADR 0015](./docs/adr/0015-non-writable-columns.md), which narrows [ADR 0004](./docs/adr/0004-updates-always-open.md).
1040
- - **Database-computed and identity columns are detected by the CLI**: `sqlapi-generate-schema` reads `is_generated` (PostgreSQL) / `GENERATION_EXPRESSION` (MySQL, non-empty only for a real `GENERATED ALWAYS AS (...)` column; `EXTRA` is a fallback matched on `VIRTUAL GENERATED` / `STORED GENERATED`, never on the word alone — MySQL writes `DEFAULT_GENERATED` there for any default *expression*, `DEFAULT CURRENT_TIMESTAMP` included, and such a column is writable) and prints the columns it found, per table, before writing anything — read that output. Generated columns land in the Schema's `generatedFields` and are named in a comment in the generated `Table*.ts`. Separately, `GENERATED ... AS IDENTITY` (PostgreSQL) has no `column_default` to reveal that the database fills it in: it is now flagged like MySQL's `AUTO_INCREMENT`, so the field is Optional and the CLI lists it under `excludeFromCreation` instead of emitting a mandatory primary key the database then refuses.
1041
- - **`upsertMap`**: when present for a schema, INSERT becomes upsert. PostgreSQL: `ON CONFLICT (...) DO UPDATE`. MySQL/MariaDB: `ON DUPLICATE KEY UPDATE`. Applies to both main and secondary tables.
1042
- - **`schemaOverrides`**: narrow an auto-generated field to a stricter TypeBox type (e.g. `{ email: Type.String({ format: 'email' }) }`) where `information_schema` could only report `text`. **Write bodies only** — insert, update, bulk-upsert. An override rules what the API accepts *from now on*, while the rows already stored predate it: a column narrowed to `format: 'email'` today holds whatever was accepted last year, and one made mandatory today is `NULL` on every pre-existing row. Narrowing the response would publish a promise nobody can retroactively make true, so responses and the `filters` map keep the generated type. (Nothing would break at runtime either way — Fastify does not validate responses, and `fast-json-stringify` ignores `format`/`pattern`/`minLength`/`minimum`/`enum` entirely; it acts only on `type`, by coercion.) The override is taken **verbatim**: no `Type.Optional` makes the field mandatory, no `Nullable` rejects an explicit `null`, whatever the column allows — which is how you make a column the DB had to leave nullable (a new column on a populated table) mandatory going forward. The original Schema file is never modified; the primary key is mandatory in the update body (it identifies the row); overrides appear in Swagger.
1043
- - **`afterRead`**: the read-side counterpart of `beforeInsert`/`beforeUpdate` — the place to decrypt a column, unpack a blob, rescale a stored unit. Called **once per result set** with camelCase rows mutated **in place** (a hook that calls a remote KMS gets one invocation for the page, not one per row), and awaited. Declared on the table that **owns** the column, and it follows a join: the hook runs wherever that table's rows surface, including through a `joinMultiple`/`joinLeft` declared on another table — `ctx.source` (`'get' | 'search' | 'joinMultiple' | 'joinLeft'`) and `ctx.alias` say which. Not called on `joinGroup` (aggregates, not table rows) nor on an empty result. Rows cannot be added or removed — the pagination `COUNT` has already run, so dropping rows would report a `total` that does not match; narrowing what a caller sees is `filters`/`tenantScope`/`readExclude`. On the main table it runs **after** the join side queries, which correlate on the values the database returned: a hook rewriting the column a relation joins on does not break the correlation. Two things it does not reach, by design: `filters`, `orderBy` and `compute*` run in SQL against the **stored** value (searching an encrypted column searches the ciphertext), and it transforms **values, not types** — the response is serialized against the generated schema, so a hook returning an object where the column is `text` yields `"[object Object]"`, and one returning `12.34` on an `integer` column yields `12`. Decrypting `text`→`text` or rescaling `numeric`→`numeric` is what it is for; if the stored and API types genuinely differ, that is a custom route.
1044
- - **Validation receives camelCase**: `validate` receives the original camelCase record (as sent by the client) and secondaries. Field names match the schema definition, with full TypeScript inference (`main.startDate`, not `main.start_date`). It returns `ValidationError[]` — tuples of `[field, code]` or `[field, code, message]`. If any errors are returned, the request is rejected with 400 before hooks or SQL execute.
1045
- - **`validateBulk` replaces `validate` in bulk**: when `validateBulk` is defined, it is called once with all items and per-item `validate` is skipped. This allows optimized batch queries instead of N individual checks. When only `validate` is defined, it runs per-item as fallback.
1046
- - **All hooks and validators receive camelCase records**: `validate` and the whole hook matrix (`beforeInsert`/`afterInsert`, `beforeUpdate`/`afterUpdate`, `beforeDelete`/`afterDelete`, `beforeBulkDelete`/`afterBulkDelete`, `afterRead`) get records keyed by schema field names (camelCase). Mutations propagate to the SQL (plugin converts to DB column format via `colMap` after the hook). The engine internally uses `snakecaseRecord(..., schema)` after user mutations to map field names to actual DB columns.
1047
- - **Secondaries run the child table's `beforeInsert`**: a child row written alongside a main record is a write like any other — the child's `excludeFromCreation`, its `tenantScope` and its `beforeInsert` all apply, in that order, before the engine auto-fills the FK to main (which always wins over anything the payload or the hook put there). Its `validate` and `afterInsert` deliberately do **not** run: they change when a rejection or a side effect fires, which belongs to the host's write. See [ADR 0014](./docs/adr/0014-what-follows-a-write-join.md).
1048
- - **Filters validation**: TypeBox schemas use `additionalProperties: false`. By default Fastify strips unknown fields silently. For 400 errors on unknown filters: `Fastify({ ajv: { customOptions: { removeAdditional: false } } })`.
1049
- - **Write-body whitelist**: insert/update/bulk-upsert bodies (`main` + secondaries items) are `additionalProperties: false`. Unknown properties are rejected with 400 — only columns in the generated Schema (as narrowed by `schemaOverrides`/`excludeFromCreation`) can be written, so no mass assignment of unexposed columns. Exclude a sensitive column at generation time (`excludeColumns`) to keep it out of the Schema, hence unwritable and unreadable.
1050
- - **Error responses**: errors the plugin raises itself are `4xx` with messages written for clients (validation, not found, tenant). Anything else — a driver constraint violation, a hook throwing without a `statusCode` — answers a fixed `{"statusCode":500,"error":"Internal Server Error","message":"Internal Server Error"}`: driver messages name tables, columns and constraints, and they stay on the server. **No status code is remapped** (a unique violation is a `500`, not a `409` — that mapping is product logic). The body carries `requestId` (the `reqId` Fastify logs on every line) so the real error is one grep away in the log, where it is also written via `request.log.error` — and attached as `err.cause`, so a consumer `setErrorHandler` can map `err.cause.code === '23505'` to `409` itself. **`exposeDebugInfo: true` is the only way to get the driver detail on the wire** — set it while developing, where a client building against the API cannot always read your log: the `500` then also carries `debugInfo` (`message`, `code`, `constraint`, `detail`, `stack`), additively, so the four fields above keep their production shape. Nothing else flips it: not `debug` (server-side only, and it belongs to whoever registers the plugin), not `NODE_ENV`. Errors thrown by `onRequests` hooks run before the handler and are unaffected.
1051
- - **Request limits**: `maxItemsPerPage` (default 1000) caps the search page size and is applied as the `LIMIT` even when no paginator is sent (so an empty-body search can't dump a whole table); over-limit `itemsPerPage` → 400. `maxBulkItems` (default 1000) caps the bulk array length via schema `maxItems` → 400. Two further caps are **fixed, not configurable**, and bound how much work one search may ask for: at most **100 `conditions`** and **20 comma-separated `orderBy` parts** — each dotted condition and each 3-part `orderBy` token becomes its own correlated subquery, which `maxItemsPerPage` does not bound. Over either limit → 400; the request schema also caps `orderBy` at 1024 characters. Those two live in the **search engine**, not in the route, so they apply to programmatic `sqlApi.search()` as well, together with the `paginator` integer guard. What `sqlApi.*` does *not* inherit are the two configurable caps, which belong to the routes: pass `maxRows` to `sqlApi.search()` for the same `LIMIT` protection, and bound your own arrays before a bulk call.
1052
- - **Tenant filtering**: when `tenantScope` is set on a table and `getTenantId` is provided in plugin options, all CRUD operations are automatically scoped to the tenant. `getTenantId` returning `null`/`undefined` = admin (no filter). Returning an array = multi-tenant user (IN clause). The tenant belongs to the *request*, not to the table it addresses: a scoped table is filtered even when reached as a join target or written as a secondary of an unscoped host, so `getTenantId` must tolerate a request with no authenticated user (`req.user?.x ?? null`).
1053
-
1054
- ---
1055
-
1056
- ## Common Backend Patterns
1057
-
1058
- ### Auth middleware (global)
1059
-
1060
- ```typescript
1061
- const opts = {
1062
- DbTables: dbTables,
1063
- onRequests: [async (req, reply) => {
1064
- if (!req.headers.authorization) return reply.status(401).send({ error: 'Unauthorized' });
1065
- }],
1066
- };
266
+ ```ts
267
+ tenantScope: { column: 'organization_id' } // direct
268
+ tenantScope: { column: 'organization_id', // indirect, via parent FK
269
+ through: { schema: SchemaCustomer, localField: 'customer_id', foreignField: 'id' } }
270
+ tenantScope: { anyOf: ['requester_agent_id', 'target_agent_id'] } // row owned by either party
1067
271
  ```
1068
272
 
1069
- ### Auth middleware (per-table)
1070
-
1071
- ```typescript
1072
- const TableAdmin = defineTable({
1073
- primary: 'id',
1074
- ...exportTableInfo(SchemaAdmin),
1075
- onRequests: [async (req, reply) => {
1076
- if (req.user.role !== 'admin') return reply.status(403).send({ error: 'Forbidden' });
1077
- }],
1078
- });
1079
- ```
1080
-
1081
- ### Custom validation (field-level, cross-entity)
1082
-
1083
- ```typescript
1084
- import type { ValidationError } from 'fastify-auto-sqlapi';
1085
-
1086
- const TableSession = defineTable({
1087
- primary: 'id',
1088
- ...exportTableInfo(SchemaSession),
1089
- allowedWriteJoins: [
1090
- buildRelation(SchemaSession, 'id', SchemaPeriod, 'sessionId', { alias: 'periods' }),
1091
- ],
1092
- validate: async (db, req, main, secondaries) => {
1093
- // ValidationError is a tuple: [field, code] or [field, code, message]
1094
- // message defaults to code if omitted
1095
- const errors: ValidationError[] = [];
1096
-
1097
- // Simple field validation
1098
- if (!main.name) {
1099
- errors.push(['name', 'required']); // message defaults to 'required'
1100
- }
1101
-
1102
- // Async validation (uniqueness check)
1103
- if (main.code) {
1104
- const existing = await db.query('SELECT 1 FROM session WHERE code = $1 AND id != $2', [main.code, main.id ?? 0]);
1105
- if (existing.rows.length) {
1106
- errors.push(['code', 'unique', 'already exists']);
1107
- }
1108
- }
1109
-
1110
- // Cross-entity: validate periods don't overlap (secondaries keyed by alias)
1111
- const periods = secondaries?.periods;
1112
- if (periods?.length) {
1113
- for (let i = 0; i < periods.length; i++) {
1114
- for (let j = i + 1; j < periods.length; j++) {
1115
- if (periods[i].startDate < periods[j].endDate && periods[j].startDate < periods[i].endDate) {
1116
- errors.push([`periods[${j}].startDate`, 'overlap', 'overlaps with another period']);
1117
- }
1118
- }
1119
- }
1120
- }
1121
-
1122
- return errors;
1123
- },
1124
- });
1125
- ```
273
+ All names in a scope are real columns. Naming a camelCase field whose column differs throws at startup. `anyOf` cannot be combined with `column`/`through` (throws). Clients still send the camelCase field.
1126
274
 
1127
- `validate` runs on insert, update, and each item in bulk-upsert (when `validateBulk` is not defined). When `validateBulk` is defined, it **replaces** per-item `validate` in bulk operations — use it for optimized batch queries and cross-item validation:
1128
-
1129
- ```typescript
1130
- const TableSession = defineTable({
1131
- // ...
1132
- validateBulk: async (db, req, items) => {
1133
- // items: Array<{ main, secondaries? }>
1134
- // Check for duplicate codes across all items in the batch
1135
- const codes = items.map(i => i.main.code).filter(Boolean);
1136
- const unique = new Set(codes);
1137
- if (unique.size !== codes.length) {
1138
- return [['code', 'batch_unique', 'duplicate codes in batch']];
1139
- }
1140
- return [];
1141
- },
1142
- });
1143
- ```
275
+ | | direct | indirect | anyOf |
276
+ |---|---|---|---|
277
+ | read, get, delete | `AND col IN (...)` | INNER JOIN parent + WHERE | `AND (a IN (...) OR b IN (...))`; NULL never matches; all-NULL row = admin only; outside scope → 404 |
278
+ | insert | single tenant: injected; mismatch → 403; multi-tenant without value → 400. Column is optional in the body | FK must belong to tenant (1 query) → 403 | no injection: at least one listed column must hold a tenant id (400 none, 403 all foreign) |
279
+ | update | column stripped from SET, added to WHERE | pre-check → 404; changing the FK to another tenant → 403 | all listed columns stripped from SET; outside scope → 404 |
280
+ | bulk upsert | injected on every row; `upsertMap` conflict key owned by another tenant → 403 | as insert | anchor rule per item, even for updates; listed columns never in UPDATE SET; conflict with a row outside scope → 403 |
281
+ | joins | filtered | filtered | predicate applied in every join family (joinLeft: in the ON clause) |
1144
282
 
1145
- ### Auto-fill audit fields
283
+ `anyOf` children written as secondaries follow the anchor rule: the auto-filled parent FK is not an owner.
1146
284
 
1147
- ```typescript
1148
- const TableCustomer = defineTable({
1149
- primary: 'id',
1150
- ...exportTableInfo(SchemaCustomer),
1151
- beforeInsert: async (db, req, record) => {
1152
- // camelCase — schema field names. The plugin converts to the actual DB column names.
1153
- record.createdBy = req.user.id;
1154
- record.createdAt = db.expression('NOW()');
1155
- },
1156
- beforeUpdate: async (db, req, fields) => {
1157
- fields.updatedBy = req.user.id;
1158
- fields.updatedAt = db.expression('NOW()');
1159
- },
1160
- });
1161
- ```
285
+ ## sqlApi (application code)
1162
286
 
1163
- ### PostgreSQL driver type parsers (numeric/int8 as strings, timestamp shifts)
287
+ Same code path, bodies and results as the HTTP routes (AGENTS_FRONTEND.md). Validation, hooks, computed fields and tenant scope all run.
1164
288
 
1165
- The `pg` driver — not the plugin — returns `numeric`/`int8` as **strings** (precision
1166
- safety), parses `date` into a JS `Date` at local midnight, and interprets
1167
- `timestamp without time zone` in the server's local timezone. The plugin deliberately does
1168
- not touch `pg.types` (global process state — see the design principles). If your API should
1169
- return numbers and stable date strings, configure the parsers once at startup, before
1170
- creating the pool:
289
+ ```ts
290
+ app.get('/billing/:customerId', async (request) =>
291
+ app.sqlApi.search('subscription', {
292
+ filters: { customerId: Number(request.params.customerId), status: 'active' },
293
+ joinMustExist: { orders: { filters: { status: 'pending' } } },
294
+ paginator: { page: 1, itemsPerPage: 50 },
295
+ }, request)); // pass `request` for tenant + hooks; without it: no tenant filter, hooks get undefined
1171
296
 
1172
- ```typescript
1173
- import pg from 'pg';
297
+ sqlApi.search(table, { filters, conditions, joinMustExist, joinMultiple, joinGroup, joinLeft, selectComputed, orderBy, paginator, computeMin, computeMax, computeSum, computeAvg, maxRows }, req?)
298
+ sqlApi.get(table, id, req?)
299
+ sqlApi.insert(table, { record, secondaries? }, req?)
300
+ sqlApi.update(table, { record, secondaries?, deletions? }, req?)
301
+ sqlApi.delete(table, id, req?)
302
+ sqlApi.bulkUpsert(table, items, req?) // each item writes only its own fields; items need not share keys
303
+ sqlApi.bulkDelete(table, ids, req?)
1174
304
 
1175
- pg.types.setTypeParser(pg.types.builtins.INT8, (v) => parseInt(v, 10));
1176
- pg.types.setTypeParser(pg.types.builtins.NUMERIC, (v) => parseFloat(v));
1177
- // Keep dates/timestamps as verbatim strings (no local-timezone Date conversion).
1178
- // Recommended when the DB stores UTC in `timestamp without time zone` (several ERPs do).
1179
- pg.types.setTypeParser(pg.types.builtins.DATE, (v) => v);
1180
- pg.types.setTypeParser(pg.types.builtins.TIMESTAMP, (v) => v);
305
+ // Standalone (jobs, scripts, tests). Always pass dialect, default is postgres syntax.
306
+ const sqlApi = createSqlApi(mysqlQueryable(pool), dbTables, { dialect: 'mysql' });
307
+ const sqlApi2 = createSqlApi(createQueryClient(pgQueryable(pgPool), 'postgres'), dbTables, { dialect: 'postgres' });
1181
308
  ```
1182
309
 
1183
- Note: HTTP responses already coerce a numeric string like `"42"` to a number when the
1184
- schema says integer — the parsers matter for programmatic `sqlApi.*` results, arithmetic in
1185
- hooks, and timestamp correctness.
1186
-
1187
- ### Translated jsonb fields (multi-language columns)
310
+ Limits: the engine enforces at most 100 `conditions` per section, 20 `orderBy` parts and an integer `paginator`, for sqlApi too. `maxItemsPerPage` and `maxBulkItems` belong to the routes only: pass `maxRows` to `search`, and cap your own bulk arrays.
1188
311
 
1189
- Some applications (several ERPs among them) store translatable columns as jsonb:
1190
- `{"en_US": "Bike", "it_IT": "Bici"}`. Expose the translation as a computed field — usable
1191
- in `filters`, `conditions`, `orderBy` and (opt-in) projected via `selectComputed`:
312
+ ## db (QueryClient) and ConditionBuilder
1192
313
 
1193
- ```typescript
1194
- const LANG = process.env.APP_LANG || 'en_US';
314
+ `db` is the hook parameter, or `createQueryClient(pgQueryable(pool) | mysqlQueryable(pool), dialect)`. Use it only for what sqlApi cannot express. It takes real table and column names (`Schema.tableName`, `Schema.col()`), and a camelCase name here is a runtime SQL error. It runs no hooks, no validation and no tenant scope.
1195
315
 
1196
- const TableProductTemplate = defineTable({
1197
- primary: 'id',
1198
- ...exportTableInfo(SchemaProductTemplate),
1199
- computedFields: {
1200
- displayName: ({ qiCol }) => ({
1201
- expr: `${qiCol('name')}->>'${LANG}'`,
1202
- type: Type.String(),
1203
- }),
1204
- },
1205
- });
1206
- // → POST /search/product_template { "filters": { "displayName": "Bike" } }
1207
- // or body { "selectComputed": ["displayName"] } to project it in the response
316
+ ```ts
317
+ db.insert(table, record, pkCol) // → PK row
318
+ db.insertOrUpdate(table, record, conflictKeys, pkCol) // → PK row
319
+ db.bulkInsert(table, records, pkCol, chunkSize?) // → PK rows in input order
320
+ db.bulkInsertOrUpdate(table, records, conflictKeys, pkCol, chunkSize?)
321
+ db.update(table, record, where, extraCondition?) // → affected rows (number)
322
+ db.delete(table, where) // → affected rows
323
+ db.select({ tableName, columns?, where, values, limit?, orderBy?, joins?, distinct? })
324
+ db.query(sql, values) // dialect placeholders ($1 on PG, ? on MySQL)
325
+ db.expression('NOW()') // raw SQL value, not parameterized
326
+ db.qi(name); db.dialectName; db.cbDialect // quoting, dialect, ConditionBuilder dialect
1208
327
  ```
1209
328
 
1210
- ### Protect sensitive fields on update (isAdmin, roles, ownership)
1211
-
1212
- Every Schema field is updatable by default — the plugin does not impose field-level rules
1213
- (see `excludeFromCreation` in Key Conventions). Encode yours in the hooks:
329
+ - `pkCol` is a string or an array. Bulk methods issue one statement per distinct column set.
330
+ - `conflictKeys` must be a unique key enforced by the DB. PG uses `ON CONFLICT (keys)`. MySQL/MariaDB upsert by key (a probe SELECT per chunk, then INSERT new and UPDATE existing), because `ON DUPLICATE KEY` fires on any unique index. A key matching several rows is refused.
331
+ - Called directly, none of the `defineTable` checks apply.
1214
332
 
1215
- ```typescript
1216
- const TableUser = defineTable({
1217
- primary: 'id',
1218
- ...exportTableInfo(SchemaUser),
1219
- // Silent strip: non-admins simply cannot touch the flag
1220
- beforeUpdate: async (db, req, fields) => {
1221
- if (!req.user.isAdmin) delete fields.isAdmin;
1222
- },
1223
- // Or loud 400 via validate:
1224
- // validate: async (db, req, main) => {
1225
- // if (main.isAdmin !== undefined && !req.user.isAdmin)
1226
- // return [['isAdmin', 'forbidden']];
1227
- // return [];
1228
- // },
1229
- });
333
+ ```ts
334
+ const cb = new ConditionBuilder('AND', db.cbDialect); // or 'OR'. Operands are raw SQL: db.qi() them.
335
+ cb.isEqual(col, v); // also isNotEqual, is[Not]Greater[OrEqual], is[Not]Less[OrEqual]
336
+ cb.isLike(col, '%v%'); // also isNotLike, isILike, isNotILike
337
+ cb.isBetween(col, from, to); // also isNotBetween; a missing bound → >= / <=
338
+ cb.isIn(col, [a, b]); // also isNotIn
339
+ cb.isNull(col, true); // also isNotNull; the boolean enables the condition
340
+ cb.raw('ST_Distance(point, ?) < ?', [p, 100]); // `?` markers; backend only
341
+ cb.append(otherBuilder); // nesting; backend only
342
+ // A value of undefined skips the condition. Everything is parameterized.
1230
343
  ```
1231
344
 
1232
- For privileged transitions (promote to admin, move across tenants) prefer a dedicated
1233
- endpoint with its own auth/audit, and keep the operation off the auto routes via `operations`.
1234
-
1235
- ### Conditional update — claim a row atomically
1236
-
1237
- The `update` route matches the primary key (plus the tenant scope) and nothing else, so it
1238
- cannot express "update only if the row is still in the state I saw": claiming a row that two
1239
- clients must not both claim, a guarded state transition, optimistic locking on a `version`
1240
- column. There is deliberately no auto route for that, nor for updating every row matching a
1241
- filter ([ADR 0020](./docs/adr/0020-no-conditional-or-mass-update-route.md)). Write it as a
1242
- custom route: the check and the write must be **one** `UPDATE`, which `db.update()` does with
1243
- an `extraCondition`, returning how many rows it changed.
1244
-
1245
- ```typescript
1246
- import { ConditionBuilder, createQueryClient, pgQueryable, Type } from 'fastify-auto-sqlapi';
1247
- import { SchemaRequest } from './schemas/SchemaRequest';
345
+ Example: atomically claiming a row, i.e. a guarded state transition or optimistic lock. There is no auto route for this (ADR 0020). The check and the write must be one UPDATE:
1248
346
 
347
+ ```ts
1249
348
  const db = createQueryClient(pgQueryable(fastify.pg), 'postgres');
1250
-
1251
- fastify.post('/requests/:id/claim', {
1252
- schema: { params: Type.Object({ id: Type.Integer() }) },
1253
- }, async (req, reply) => {
1254
- const { id } = req.params as { id: number };
1255
- const col = (f: string) => SchemaRequest.col(f); // real column names: QueryClient speaks the DB's
1256
-
349
+ fastify.post('/requests/:id/claim', async (req, reply) => {
350
+ const col = (f: string) => SchemaRequest.col(f);
1257
351
  const guard = new ConditionBuilder('AND', db.cbDialect)
1258
- .isNull(db.qi(col('inProgressSince')), true) // the precondition — second arg enables it
1259
- .isEqual(db.qi(col('organizationId')), req.user.orgId); // tenant: QueryClient applies none for you
1260
- // Record/where keys are quoted by db.update(); ConditionBuilder operands are raw SQL, so qi() them.
1261
-
1262
- const affected = await db.update(
1263
- SchemaRequest.tableName,
352
+ .isNull(db.qi(col('inProgressSince')), true) // the precondition
353
+ .isEqual(db.qi(col('organizationId')), req.user.orgId); // tenant: db applies none
354
+ const affected = await db.update(SchemaRequest.tableName,
1264
355
  { [col('inProgressSince')]: new Date(), [col('inProgressBy')]: req.user.id },
1265
- { [col('id')]: id },
1266
- guard
1267
- );
1268
- if (affected === 0) return reply.code(409).send({ message: 'Already claimed, or not found' });
1269
- return { claimed: true };
356
+ { [col('id')]: req.params.id }, guard);
357
+ return affected === 0 ? reply.code(409).send({ message: 'Already claimed or not found' }) : { claimed: true };
1270
358
  });
359
+ // Keep the predicate server-chosen: a client-chosen compare column makes `affected` an oracle.
1271
360
  ```
1272
361
 
1273
- `QueryClient` runs no hooks, no validation and **no tenant scoping**: the route owns all three.
1274
- Keep the predicate server-chosen, as above. Letting the client name the column it compares on
1275
- turns `affected` into an oracle on values it cannot read.
1276
-
1277
- ### Encrypt a column at rest (storage representation ≠ API representation)
1278
-
1279
- Three declarations on the table that owns the column — in on the write hooks, out on the read
1280
- hook — and nothing at the call sites:
1281
-
1282
- ```typescript
1283
- const TablePatient = defineTable({
1284
- primary: 'id',
1285
- ...exportTableInfo(SchemaPatient),
1286
-
1287
- beforeInsert: async (db, req, record) => {
1288
- if (record.ssn != null) record.ssn = encrypt(record.ssn);
1289
- },
1290
- beforeUpdate: async (db, req, fields) => {
1291
- if (fields.ssn != null) fields.ssn = encrypt(fields.ssn);
1292
- },
1293
- afterRead: async (db, req, rows) => {
1294
- for (const row of rows) {
1295
- if (row.ssn != null) row.ssn = decrypt(row.ssn);
1296
- }
1297
- },
1298
- });
1299
- ```
1300
-
1301
- `afterRead` covers `GET /rest/patient/:id`, `POST /search/patient`, and every `joinMultiple` /
1302
- `joinLeft` that reaches `patient` from another table's search — the hook belongs to the table
1303
- that owns the column, so a relation declared elsewhere needs no extra wiring. The same holds on
1304
- the way in: a patient written as a **secondary** of another table's insert runs this
1305
- `beforeInsert` too.
1306
-
1307
- What the plugin does *not* do for you, because it cannot know your cipher:
1308
-
1309
- - **searching**: `filters: { ssn: '123-45' }` compares the plaintext against the ciphertext in
1310
- SQL and matches nothing. With a deterministic cipher, encrypt the value yourself in
1311
- `extendedCondition`; with a randomized one, that column is not searchable at all. `orderBy`
1312
- and `computeMin/Max` sort and aggregate the ciphertext.
1313
- - **typing**: the hook transforms values, not types. The response is serialized against the
1314
- generated schema, so returning an object where the column is `text` yields
1315
- `"[object Object]"`. `text`→`text` and `numeric`→`numeric` transforms are the supported shape.
1316
- - **aggregates**: `joinGroup` and `compute*` return values the hook never sees.
1317
-
1318
- ### Full-text search filter
1319
-
1320
- ```typescript
1321
- const extraFilters = Type.Object({ q: Type.String() });
1322
-
1323
- ...exportTableInfo(Schema, extraFilters, (condition, filters) => {
1324
- if (filters.q) {
1325
- const or = new ConditionBuilder('OR');
1326
- or.isILike(Schema.col('name'), `%${filters.q}%`);
1327
- or.isILike(Schema.col('email'), `%${filters.q}%`);
1328
- or.isILike(Schema.col('phoneNumber'), `%${filters.q}%`);
1329
- condition.append(or);
1330
- }
1331
- }),
1332
- ```
1333
-
1334
- ### Date range filter
1335
-
1336
- ```typescript
1337
- const extraFilters = Type.Object({
1338
- dateFrom: Type.String(),
1339
- dateTo: Type.String(),
1340
- });
1341
-
1342
- ...exportTableInfo(Schema, extraFilters, (condition, filters) => {
1343
- if (filters.dateFrom && filters.dateTo) {
1344
- condition.isBetween(Schema.col('orderDate'), filters.dateFrom, filters.dateTo);
1345
- } else if (filters.dateFrom) {
1346
- condition.isGreater(Schema.col('orderDate'), filters.dateFrom);
1347
- }
1348
- }),
1349
- ```
1350
-
1351
- ### Multi-tenant from JWT/header
1352
-
1353
- ```typescript
1354
- await app.register(fastifyAutoSqlApi, {
1355
- DbTables: dbTables,
1356
- getTenantId: (request) => {
1357
- // From JWT claims
1358
- return request.user?.organizationId ?? null;
1359
-
1360
- // Or from header (multi-tenant support)
1361
- // const header = request.headers['x-tenant-id'] as string | undefined;
1362
- // if (!header) return null;
1363
- // const ids = header.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
1364
- // return ids.length === 0 ? null : ids.length === 1 ? ids[0] : ids;
1365
- },
1366
- });
1367
- ```
1368
-
1369
- ### Register only specific routes
1370
-
1371
- You don't have to register all route plugins. Pick only what you need:
1372
-
1373
- ```typescript
1374
- await instance.register(searchRoutes, opts); // read-only API
1375
- await instance.register(getRoutes, opts);
1376
- // No insert/update/delete/bulk routes = read-only
1377
- ```
1378
-
1379
- ### Multiple prefixes for different access levels
1380
-
1381
- ```typescript
1382
- // Public: search + get only
1383
- await app.register(async (instance) => {
1384
- const opts = { DbTables: publicTables };
1385
- await instance.register(searchRoutes, opts);
1386
- await instance.register(getRoutes, opts);
1387
- }, { prefix: '/public' });
362
+ ## Views
1388
363
 
1389
- // Admin: full CRUD
1390
- await app.register(async (instance) => {
1391
- const opts = { DbTables: allTables, onRequests: [adminAuth] };
1392
- await instance.register(searchRoutes, opts);
1393
- await instance.register(getRoutes, opts);
1394
- await instance.register(insertRoutes, opts);
1395
- await instance.register(updateRoutes, opts);
1396
- await instance.register(deleteRoutes, opts);
1397
- await instance.register(bulkUpsertRoutes, opts);
1398
- await instance.register(bulkDeleteRoutes, opts);
1399
- }, { prefix: '/admin' });
1400
- ```
364
+ A view (materialized ones included) is an ordinary table config (ADR 0016).
1401
365
 
1402
- ### LLM / agent surface (read-only + manifest)
366
+ - **Primary key.** A view declares no PK, and the generator never guesses one from the column types.
367
+ - If the view has an `id` column, it is used and flagged as inferred. Check it is unique before enabling get, update or delete.
368
+ - Otherwise the generator writes `primary: 'TODO_pick_a_unique_column'`, `operations: ['search']` and a real `defaultOrder`. That config works as is.
369
+ - `defineTable` throws on an unknown `primary` unless the table is search-only with its own `defaultOrder`.
370
+ - **Writes.** The generated template sets `operations: ['search', 'get']`, but this is not a runtime rule. Simple updatable views accept writes on PG and MySQL. Aggregating views fail at the DB with a 500.
371
+ - **Nullability.** PG reports every view column as nullable, MySQL reports the real nullability. The generated Schemas therefore differ per dialect. This affects write bodies only.
1403
372
 
1404
- Give a chat agent direct data access with the plugin as enforcement layer (tenant scoping,
1405
- `readExclude`, caps still apply to whatever the model invents). Read-only surface + the
1406
- manifest endpoint, agent-specific auth:
373
+ ## Agent surface
1407
374
 
1408
- ```typescript
1409
- import { searchRoutes, getRoutes, agentManifestRoutes } from 'fastify-auto-sqlapi';
375
+ A read-only surface for LLM agents, with the plugin as the enforcement layer:
1410
376
 
377
+ ```ts
1411
378
  await app.register(async (instance) => {
1412
379
  const opts = { DbTables: dbTables, onRequests: [agentAuth] };
1413
380
  await instance.register(searchRoutes, opts);
1414
381
  await instance.register(getRoutes, opts);
1415
- await instance.register(agentManifestRoutes, opts); // GET /agent/manifest(.md)
382
+ await instance.register(agentManifestRoutes, opts); // GET /agent/manifest(.md)
1416
383
  }, { prefix: '/agent' });
1417
384
  ```
1418
385
 
1419
- System prompt = `AGENTS_FRONTEND.md` (request grammar, ships in the package) + the output of
1420
- `GET /agent/manifest.md` (this deployment's tables/fields/aliases). On the main plugin the
1421
- manifest is enabled with the `agentManifest: true` option. Validation strategy: either a
1422
- loose generic tool (`table` enum from the manifest, free body) with the structured 400
1423
- `fields[]` as the retry signal, or strict provider-side tools via
1424
- `agentToolSchemas(dbTables, table)` — the exact JSON Schemas the routes validate with.
1425
-
1426
- ---
1427
-
1428
- ## Re-exports
1429
-
1430
- The package re-exports commonly needed utilities so you don't need to install them separately:
1431
-
1432
- ```typescript
1433
- import {
1434
- Type, // from @sinclair/typebox
1435
- type Static, // from @sinclair/typebox
1436
- ConditionBuilder, // from node-condition-builder
1437
- Expression, // from node-condition-builder
1438
-
1439
- // Table configuration
1440
- defineTable,
1441
- exportTableInfo,
1442
- buildRelation,
1443
- buildUpsertRule,
1444
- buildUpsertRules,
1445
- Nullable, // type-array nullable schema (see Conventions)
1446
-
1447
- // DB layer
1448
- QueryClient, // raw SQL query helper
1449
- createQueryClient, // factory with dialect string
1450
- pgQueryable, // pg pool adapter
1451
- mysqlQueryable, // mysql2 pool adapter
1452
-
1453
- // Programmatic high-level API
1454
- createSqlApi, // standalone SqlApi (for scripts/tests)
1455
- setupSwagger, // manual Swagger registration
1456
- } from 'fastify-auto-sqlapi';
1457
-
1458
- import type {
1459
- ValidationError, // [field, code] | [field, code, message]
1460
- ValidatorFn,
1461
- BulkValidatorFn,
1462
- JoinDefinition,
1463
- JoinRefFilter,
1464
- JoinFetchRequest,
1465
- JoinGroupRequest,
1466
- SearchCondition,
1467
- ConditionMethod,
1468
- } from 'fastify-auto-sqlapi';
1469
- ```
1470
-
1471
- After registering the plugin, `app.sqlApi` is decorated on the Fastify instance and exposes `search`, `get`, `insert`, `update`, `delete`, `bulkUpsert`, `bulkDelete` for custom routes — same code path as the auto-generated endpoints.
1472
-
1473
- ---
1474
-
1475
- ## FAQ / Gotchas
386
+ - **System prompt**: AGENTS_FRONTEND.md (grammar, shipped in the package) + the output of `GET /agent/manifest.md` (this deployment's tables, fields and aliases).
387
+ - **Tools**: either a loose generic tool, retrying on the structured 400 `fields[]`, or strict per-table tools from `agentToolSchemas(dbTables, table)`.
1476
388
 
1477
- ### Schema files `default export` — do I need to register them?
389
+ ## Exports
1478
390
 
1479
- No. The generated Schema files export a default Fastify plugin that calls `fastify.addSchema()`. **You do NOT need to register them manually.** The plugin works without it. The default export exists only for advanced use cases where you want `$ref` schema resolution in Swagger. If you don't need it, ignore it.
391
+ Everything is imported from `fastify-auto-sqlapi`, so there is nothing else to install:
1480
392
 
1481
- ### `sqlapi.config.ts` module warning
1482
-
1483
- When running the CLI, Node may show: `Module type of file:///...sqlapi.config.ts is not specified...`. This is harmless — Node detects `export default` and reparses as ESM. To suppress it, ensure your project has `"type": "module"` in `package.json`, or rename the config to `sqlapi.config.mjs`.
1484
-
1485
- ### TypeBox version conflicts
1486
-
1487
- The CLI generates Schema files that import `Type` and `Static` from `fastify-auto-sqlapi` (not from `@sinclair/typebox` directly). This ensures the consumer uses the same TypeBox version bundled with the plugin, avoiding `[Kind]` type errors from duplicate TypeBox installations.
1488
-
1489
- ### Prefix behavior
1490
-
1491
- The `prefix` is a standard Fastify register option. Pass it alongside the plugin options:
1492
-
1493
- ```typescript
1494
- await app.register(fastifyAutoSqlApi, { DbTables: dbTables, prefix: '/api' });
1495
- ```
1496
-
1497
- The plugin internally strips `prefix` before passing options to sub-route plugins, so it is applied only once. Without prefix, routes are at root (`/search/customer`, `/rest/customer/:id`, etc.).
1498
-
1499
- ### Dialect differences
1500
-
1501
- | | PostgreSQL | MySQL | MariaDB |
1502
- |---|---|---|---|
1503
- | **Identifier quoting** | `"id"` | `` `id` `` | `` `id` `` |
1504
- | **Placeholders** | `$1, $2` | `?, ?` | `?, ?` |
1505
- | **RETURNING** | Yes | No | Yes (10.5+) |
1506
- | **Upsert syntax** | `ON CONFLICT ... DO UPDATE` | `ON DUPLICATE KEY UPDATE` | `ON DUPLICATE KEY UPDATE` |
1507
- | **Auto-increment PK** | Via `RETURNING` | Via `insertId` | Via `RETURNING` |
1508
- | **CLI env vars** | `POSTGRES_*` | `MYSQL_*` | `MYSQL_*` |
1509
- | **CLI introspection** | `pg` | `mysql2` | `mysql2` |
393
+ - `Type`, `Static`, `ConditionBuilder`, `Expression`, `Nullable`
394
+ - `defineTable`, `exportTableInfo`, `buildRelation`, `buildUpsertRule(s)`
395
+ - `fastifyAutoSqlApi`, `*Routes`, `agentManifestRoutes`, `setupSwagger`
396
+ - `SqlApi`, `createSqlApi`, `QueryClient`, `createQueryClient`, `pgQueryable`, `mysqlQueryable`
397
+ - `agentToolSchemas`
398
+ - Types: `ValidationError`, `ValidatorFn`, `BulkValidatorFn`, `JoinDefinition`, `SearchCondition`, `ConditionMethod`, `DbTables`
1510
399
 
400
+ Dialects: PG uses `"id"`, `$1` and `RETURNING`. MySQL uses backticks, `?` and `insertId`. MariaDB is like MySQL but has `RETURNING` (10.5+). The CLI uses `pg` / `mysql2`.