fastify-auto-sqlapi 0.3.0 → 0.4.1
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.md +24 -111
- package/AGENTS_BACKEND.md +271 -1381
- package/AGENTS_FRONTEND.md +72 -47
- package/BREAKING_CHANGES.md +129 -0
- package/CHANGELOG.md +86 -1
- package/README.md +1 -1
- package/dist/bin/generate-schema.js +2 -2
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +24 -1
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +21 -2
- package/dist/lib/cli/schema-codegen.d.ts +1 -1
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +31 -3
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +187 -60
- package/dist/lib/dialect.d.ts +15 -10
- package/dist/lib/dialect.d.ts.map +1 -1
- package/dist/lib/dialect.js +12 -31
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +2 -1
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +2 -1
- package/dist/lib/engine/search/aggregations.d.ts +7 -0
- package/dist/lib/engine/search/aggregations.d.ts.map +1 -1
- package/dist/lib/engine/search/aggregations.js +8 -7
- package/dist/lib/engine/write-helpers.d.ts +13 -1
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +35 -0
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +41 -0
- package/dist/types/cli.d.ts +11 -0
- package/dist/types/cli.d.ts.map +1 -1
- package/dist/types/schema.d.ts +7 -0
- package/dist/types/schema.d.ts.map +1 -1
- package/docs/adr/0021-upsert-targets-a-verified-unique-key.md +153 -0
- package/docs/adr/README.md +1 -0
- package/package.json +1 -1
package/AGENTS_BACKEND.md
CHANGED
|
@@ -1,546 +1,106 @@
|
|
|
1
|
-
# fastify-auto-sqlapi —
|
|
1
|
+
# fastify-auto-sqlapi — backend reference (LLM)
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+
## Rules
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
57
|
-
schema: 'public',
|
|
58
|
-
dialect: 'postgres',
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
103
|
-
npx sqlapi-generate-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
+
## Server setup
|
|
109
39
|
|
|
110
|
-
```
|
|
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,
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
onRequests: [],
|
|
162
|
-
getTenantId: (
|
|
163
|
-
maxItemsPerPage: 1000,
|
|
164
|
-
maxBulkItems: 1000,
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
283
|
-
import {defineTable, exportTableInfo} from 'fastify-auto-sqlapi';
|
|
284
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
import { SchemaCustomer as Schema } from '../schemas/SchemaCustomer';
|
|
140
|
+
// --- tenant (see Tenant scope) ---
|
|
141
|
+
tenantScope: { column: 'organization_id' }, // REAL column name
|
|
571
142
|
|
|
572
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
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
|
-
|
|
914
|
-
a transfer (`from_account_id` / `to_account_id`), a shift swap:
|
|
194
|
+
## Relations
|
|
915
195
|
|
|
916
|
-
```
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
925
|
-
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
246
|
+
## afterRead
|
|
968
247
|
|
|
969
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
+
## Tenant scope
|
|
975
256
|
|
|
976
|
-
|
|
257
|
+
`getTenantId(req)` returns one of:
|
|
977
258
|
|
|
978
|
-
-
|
|
979
|
-
|
|
980
|
-
-
|
|
981
|
-
|
|
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
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
|
|
283
|
+
`anyOf` children written as secondaries follow the anchor rule: the auto-filled parent FK is not an owner.
|
|
1146
284
|
|
|
1147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
1173
|
-
|
|
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
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
-
```
|
|
1216
|
-
const
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1259
|
-
.isEqual(db.qi(col('organizationId')), req.user.orgId);
|
|
1260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
382
|
+
await instance.register(agentManifestRoutes, opts); // GET /agent/manifest(.md)
|
|
1416
383
|
}, { prefix: '/agent' });
|
|
1417
384
|
```
|
|
1418
385
|
|
|
1419
|
-
System prompt
|
|
1420
|
-
|
|
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
|
-
|
|
389
|
+
## Exports
|
|
1478
390
|
|
|
1479
|
-
|
|
391
|
+
Everything is imported from `fastify-auto-sqlapi`, so there is nothing else to install:
|
|
1480
392
|
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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`.
|