fastify-auto-sqlapi 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +24 -111
- package/AGENTS_BACKEND.md +282 -1332
- package/AGENTS_FRONTEND.md +70 -47
- package/BREAKING_CHANGES.md +131 -0
- package/CHANGELOG.md +139 -0
- 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/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/schema/helpers.d.ts +7 -0
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +18 -0
- package/dist/lib/schema/insert.d.ts.map +1 -1
- package/dist/lib/schema/insert.js +3 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +41 -0
- package/dist/lib/tenant.d.ts +6 -0
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +16 -0
- package/dist/routes/auto/bulk-delete.routes.js +1 -1
- package/dist/routes/auto/bulk-upsert.routes.js +1 -1
- package/dist/routes/auto/delete.routes.js +1 -1
- package/dist/routes/auto/get.routes.js +1 -1
- package/dist/routes/auto/insert.routes.js +1 -1
- package/dist/routes/auto/route-helpers.d.ts +5 -2
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +1 -1
- package/dist/routes/auto/search.routes.js +3 -3
- package/dist/routes/auto/update.routes.js +1 -1
- 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/0020-no-conditional-or-mass-update-route.md +68 -0
- package/docs/adr/0021-upsert-targets-a-verified-unique-key.md +153 -0
- package/docs/adr/README.md +2 -0
- package/package.json +4 -4
package/AGENTS_BACKEND.md
CHANGED
|
@@ -1,535 +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
|
|
100
|
-
|
|
101
|
-
```bash
|
|
102
|
-
npx sqlapi-generate-schema # PostgreSQL (default)
|
|
103
|
-
npx sqlapi-generate-schema --dialect mysql # MySQL/MariaDB
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
This introspects the database and generates one `Schema*.ts` file per table in `outputDir/schemas/`. These files are auto-generated and should not be manually edited. They contain TypeBox field definitions, `col()` mapping each camelCase field to its real column, and validation schemas. MySQL/MariaDB requires `mysql2` as a peer dependency.
|
|
107
|
-
|
|
108
|
-
### 5. Generate tables template
|
|
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`.
|
|
109
27
|
|
|
110
28
|
```bash
|
|
111
|
-
npx sqlapi-generate-
|
|
112
|
-
npx sqlapi-generate-tables customer
|
|
113
|
-
npx sqlapi-generate-tables --all
|
|
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
|
|
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
|
|
127
32
|
```
|
|
128
33
|
|
|
129
|
-
|
|
130
|
-
-
|
|
131
|
-
-
|
|
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
|
|
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`.
|
|
141
37
|
|
|
142
|
-
|
|
38
|
+
## Server setup
|
|
143
39
|
|
|
144
|
-
```
|
|
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
|
-
});
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
| Option | Type | Required | Description |
|
|
242
|
-
|--------|------|----------|-------------|
|
|
243
|
-
| `DbTables` | `Record<string, ITable>` | Yes | Table configurations |
|
|
244
|
-
| `dialect` | `'postgres' \| 'mysql' \| 'mariadb'` | No | DB dialect (default: `'postgres'`) |
|
|
245
|
-
| `prefix` | `string` | No | URL prefix (e.g. `/api`) |
|
|
246
|
-
| `swagger` | `boolean \| SwaggerOptions` | No | Enable Swagger UI |
|
|
247
|
-
| `onRequests` | `Function[]` | No | Global auth/middleware hooks |
|
|
248
|
-
| `getTenantId` | `(req) => id \| null` | No | Tenant resolver for multi-tenant |
|
|
249
|
-
| `maxItemsPerPage` | `number` | No | Max search page size, and the row `LIMIT` applied when no paginator is sent (default: `1000`) |
|
|
250
|
-
| `maxBulkItems` | `number` | No | Max number of items accepted by the bulk endpoints (default: `1000`) |
|
|
251
|
-
| `debug` | `boolean` | No | Log all SQL queries to console (server-side only — it never changes a response) |
|
|
252
|
-
| `exposeDebugInfo` | `boolean` | No | **Development only.** Attach the driver error (message, `code`, `constraint`, stack) to `500` responses as `debugInfo` (default: `false`) |
|
|
253
|
-
|
|
254
|
-
The plugin picks up the connection from the Fastify instance: `fastify.pg` for PostgreSQL
|
|
255
|
-
(what `@fastify/postgres` decorates) or `fastify.mysql` for MySQL/MariaDB. For MySQL,
|
|
256
|
-
decorate a `mysql2/promise` pool (e.g. via `@fastify/mysql` with `promise: true`, or manually)
|
|
257
|
-
and pass `dialect: 'mysql'` or `'mariadb'`:
|
|
258
|
-
|
|
259
|
-
```typescript
|
|
260
|
-
import mysql from 'mysql2/promise';
|
|
261
|
-
|
|
262
|
-
const pool = mysql.createPool({ host: '127.0.0.1', user: 'myuser', password: '...', database: 'mydb' });
|
|
263
|
-
app.decorate('mysql', pool);
|
|
264
|
-
app.addHook('onClose', async () => { await pool.end(); });
|
|
265
|
-
|
|
266
|
-
await app.register(fastifyAutoSqlApi, { DbTables: dbTables, dialect: 'mysql' });
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
---
|
|
270
|
-
|
|
271
|
-
## defineTable() — Complete Reference
|
|
272
|
-
|
|
273
|
-
```typescript
|
|
274
|
-
import {defineTable, exportTableInfo, Type} from 'fastify-auto-sqlapi';
|
|
275
|
-
// Import additional utilities only when needed:
|
|
276
|
-
// import {buildRelation, buildUpsertRules, buildUpsertRule, ConditionBuilder} from 'fastify-auto-sqlapi';
|
|
277
|
-
// import type {DbTables} from 'fastify-auto-sqlapi';
|
|
76
|
+
}, { prefix: '/public' });
|
|
278
77
|
```
|
|
279
78
|
|
|
280
|
-
|
|
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:
|
|
281
80
|
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
primary: 'id',
|
|
288
|
-
...exportTableInfo(Schema),
|
|
289
|
-
});
|
|
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);
|
|
290
86
|
```
|
|
291
87
|
|
|
292
|
-
|
|
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`).
|
|
88
|
+
HTTP responses coerce `"42"` to a number anyway. The parsers matter for `sqlApi.*` results and for hook arithmetic.
|
|
295
89
|
|
|
296
|
-
|
|
90
|
+
## defineTable
|
|
297
91
|
|
|
298
|
-
|
|
299
|
-
import {defineTable, exportTableInfo} from 'fastify-auto-sqlapi';
|
|
300
|
-
import {SchemaAgentTeamLink as Schema} from '../schemas/SchemaAgentTeamLink';
|
|
301
|
-
|
|
302
|
-
export const TableAgentTeamLink = defineTable({
|
|
303
|
-
primary: ['agentId', 'teamId'],
|
|
304
|
-
...exportTableInfo(Schema),
|
|
305
|
-
});
|
|
306
|
-
```
|
|
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.
|
|
307
93
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
### All keys
|
|
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';
|
|
313
98
|
|
|
314
|
-
```typescript
|
|
315
99
|
export const TableCustomer = defineTable({
|
|
316
|
-
//
|
|
317
|
-
primary: 'id', // PK field name (camelCase), or array for composite: ['agentId', 'teamId']
|
|
318
|
-
...exportTableInfo(Schema), // Schema + auto-filter builder
|
|
319
|
-
|
|
320
|
-
// OPTIONAL
|
|
321
|
-
defaultOrder: 'name', // ORDER BY default. camelCase fields are mapped to DB
|
|
322
|
-
// columns (e.g. 'squadIndex' -> "squad_index"); supports
|
|
323
|
-
// multi ('name ASC, id DESC'), computed fields, and raw
|
|
324
|
-
// SQL fragments (unknown tokens pass through unchanged)
|
|
325
|
-
excludeFromCreation: ['id'], // Fields omitted from INSERT — MUST include auto-increment PKs
|
|
326
|
-
readExclude: ['passwordHash'], // Fields hidden from ALL reads (writes unaffected)
|
|
327
|
-
distinctResults: true, // Use SELECT DISTINCT
|
|
328
|
-
|
|
329
|
-
// JOINS — relations to other tables. Alias defaults to joinSchema.tableName —
|
|
330
|
-
// declare it explicitly only when joining the same table twice or when you want
|
|
331
|
-
// a friendlier name. Set `unique: true` for N:1 (parent) relations to enable `joinLeft`.
|
|
332
|
-
allowedReadJoins: [ // Available for search: joinMustExist / joinMultiple / joinGroup / joinLeft
|
|
333
|
-
buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order' (default)
|
|
334
|
-
buildRelation(SchemaCustomer, 'id', SchemaAddress, 'customerId', { selection: 'id, city, zip' }), // alias = 'address'
|
|
335
|
-
// N:1 example (enables joinLeft):
|
|
336
|
-
// buildRelation(SchemaSession, 'userId', SchemaUser, 'id', { unique: true }), // alias = 'user'
|
|
337
|
-
// Alias multipli sulla stessa tabella (richiede alias espliciti):
|
|
338
|
-
// buildRelation(SchemaSession, 'createdBy', SchemaUser, 'id', { alias: 'creator', unique: true }),
|
|
339
|
-
// buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true }),
|
|
340
|
-
],
|
|
341
|
-
allowedWriteJoins: [ // Available for insert/update secondaries (alias is the request body key)
|
|
342
|
-
buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
|
|
343
|
-
],
|
|
344
|
-
|
|
345
|
-
// UPSERT — ON CONFLICT resolution
|
|
346
|
-
upsertMap: buildUpsertRules(
|
|
347
|
-
buildUpsertRule(SchemaCustomer, ['id']), // main table conflict key
|
|
348
|
-
buildUpsertRule(SchemaOrder, ['id']), // secondary conflict key
|
|
349
|
-
),
|
|
350
|
-
|
|
351
|
-
// TENANT — automatic row-level isolation
|
|
352
|
-
tenantScope: { column: 'organization_id' }, // direct: column on this table
|
|
353
|
-
// OR indirect: resolve via JOIN to parent table
|
|
354
|
-
// tenantScope: {
|
|
355
|
-
// column: 'organization_id',
|
|
356
|
-
// through: { schema: SchemaCustomer, localField: 'customerId', foreignField: 'id' },
|
|
357
|
-
// },
|
|
358
|
-
// OR anyOf: several owner columns, the row is visible to any of them
|
|
359
|
-
// tenantScope: { anyOf: ['requester_agent_id', 'target_agent_id'] },
|
|
360
|
-
|
|
361
|
-
// SCHEMA OVERRIDES — tighten auto-generated schema validation without editing Schema files
|
|
362
|
-
schemaOverrides: {
|
|
363
|
-
email: Type.String({ format: 'email' }),
|
|
364
|
-
name: Type.String({ minLength: 1, maxLength: 100 }),
|
|
365
|
-
},
|
|
366
|
-
|
|
367
|
-
// VALIDATION — structured field-level validation (runs after schema, before hooks)
|
|
368
|
-
validate: async (db, req, main, secondaries) => {
|
|
369
|
-
// Return ValidationError[] — tuple: [field, code] or [field, code, message]
|
|
370
|
-
// message defaults to code if omitted
|
|
371
|
-
const errors = [];
|
|
372
|
-
if (main.name === '') {
|
|
373
|
-
errors.push(['name', 'required', 'cannot be empty']);
|
|
374
|
-
}
|
|
375
|
-
// Cross-entity: validate secondaries (e.g. date overlap in periods).
|
|
376
|
-
// Note: secondaries are keyed by alias (declared in allowedWriteJoins), not by tableName.
|
|
377
|
-
if (secondaries?.orders) {
|
|
378
|
-
// ... check overlaps, business rules across related records
|
|
379
|
-
}
|
|
380
|
-
return errors;
|
|
381
|
-
},
|
|
382
|
-
validateBulk: async (db, req, items) => {
|
|
383
|
-
// Called once with ALL items in bulk-upsert. Use for cross-item validation.
|
|
384
|
-
// items: Array<{ main, secondaries? }>
|
|
385
|
-
return []; // ValidationError[]
|
|
386
|
-
},
|
|
387
|
-
|
|
388
|
-
// HOOKS — side effects (runs after validation). All receive camelCase (schema field names).
|
|
389
|
-
beforeInsert: async (db, req, record) => {
|
|
390
|
-
// Mutate record before INSERT. Use camelCase (schema) field names — conversion is automatic.
|
|
391
|
-
record.createdBy = req.user.id;
|
|
392
|
-
},
|
|
393
|
-
afterInsert: async (db, req, record, secondaryRecords) => {
|
|
394
|
-
// Called after INSERT + secondaries. record is camelCase (input merged with generated PK).
|
|
395
|
-
},
|
|
396
|
-
beforeUpdate: async (db, req, fields) => {
|
|
397
|
-
// Mutate fields before UPDATE. camelCase. PK is present for reference but excluded from UPDATE SET.
|
|
398
|
-
fields.updatedBy = req.user.id;
|
|
399
|
-
},
|
|
400
|
-
afterUpdate: async (db, req, record, secondaryRecords, deletionRecords) => {
|
|
401
|
-
// Called after UPDATE + secondaries + deletions, INSIDE the write transaction: throwing rolls back.
|
|
402
|
-
},
|
|
403
|
-
beforeDelete: async (db, req, id) => {
|
|
404
|
-
// Throw to abort the single delete. For tenant-scoped tables runs only after ownership is verified.
|
|
405
|
-
},
|
|
406
|
-
afterDelete: async (db, req, id) => {
|
|
407
|
-
// Called after a successful single delete (not on 404).
|
|
408
|
-
},
|
|
409
|
-
beforeBulkDelete: async (db, req, ids) => {
|
|
410
|
-
// Called ONCE with all ids before a bulk delete. Throw to abort the whole batch.
|
|
411
|
-
},
|
|
412
|
-
afterBulkDelete: async (db, req, deletedIds) => {
|
|
413
|
-
// Called ONCE with the ids ACTUALLY deleted (may be a subset of the requested ids).
|
|
414
|
-
},
|
|
415
|
-
afterRead: async (db, req, rows, ctx) => {
|
|
416
|
-
// The read-side hook: turn the stored representation back into the API one.
|
|
417
|
-
// Called ONCE per result set (batch), with camelCase rows mutated IN PLACE.
|
|
418
|
-
// Runs for get, search, joinMultiple and joinLeft — including when THIS table's rows
|
|
419
|
-
// arrive through a join declared on another table (ctx.source / ctx.alias say which).
|
|
420
|
-
// Not called on joinGroup (aggregates, not rows) nor when the read returned nothing.
|
|
421
|
-
for (const row of rows) row.ssn = decrypt(row.ssn);
|
|
422
|
-
},
|
|
423
|
-
|
|
424
|
-
// OPERATIONS — whitelist of auto-generated HTTP routes for this table.
|
|
425
|
-
// Omitted = ALL operations exposed (default-open!). Does not affect programmatic sqlApi.*.
|
|
426
|
-
// operations: ['search', 'get'],
|
|
427
|
-
|
|
428
|
-
// AUTH — per-table request hooks
|
|
429
|
-
onRequests: [
|
|
430
|
-
async (request, reply) => {
|
|
431
|
-
if (!request.user) return reply.status(401).send({ error: 'Unauthorized' });
|
|
432
|
-
},
|
|
433
|
-
],
|
|
434
|
-
});
|
|
435
|
-
```
|
|
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.
|
|
436
101
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
```typescript
|
|
440
|
-
buildRelation(mainSchema, mainField, joinSchema, joinField, options?)
|
|
441
|
-
|
|
442
|
-
// where options is fully optional:
|
|
443
|
-
// {
|
|
444
|
-
// alias?: string; // default = joinSchema.tableName. The key used in request/response
|
|
445
|
-
// // payloads, secondaries/deletions, and dotted-notation orderBy/conditions.
|
|
446
|
-
// selection?: string; // default '*'. Comma-separated columns: 'id, name, total'.
|
|
447
|
-
// unique?: boolean; // default false.
|
|
448
|
-
// // false → 1:N (child→main). Allowed in joinMustExist/joinMultiple/joinGroup.
|
|
449
|
-
// // true → N:1 (parent→main). Allowed in joinLeft only.
|
|
450
|
-
// fields?: string[]; // allowlist of the target's fields reachable through this relation.
|
|
451
|
-
// // Read joins only. Must include joinField. Everything else is 400.
|
|
452
|
-
// }
|
|
453
|
-
```
|
|
454
|
-
|
|
455
|
-
- `mainField`: field(s) in main table (string or string[])
|
|
456
|
-
- `joinField`: field in join table that joins to `mainField`
|
|
457
|
-
- For 1:N (child→main): `joinField` is the FK on the child table → `mainField` is the PK on main
|
|
458
|
-
- For N:1 (parent→main): `joinField` is the PK on the parent table → `mainField` is the FK on main
|
|
459
|
-
|
|
460
|
-
**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`.
|
|
461
|
-
|
|
462
|
-
**⚠️ 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.
|
|
463
|
-
|
|
464
|
-
To expose a table broadly but keep some of its columns narrow, give the relation a `fields` allowlist:
|
|
465
|
-
|
|
466
|
-
```typescript
|
|
467
|
-
allowedReadJoins: [
|
|
468
|
-
buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true, fields: ['id', 'name'] }),
|
|
469
|
-
],
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
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 *`.
|
|
473
|
-
|
|
474
|
-
Rules, all enforced at declaration time:
|
|
475
|
-
|
|
476
|
-
- **Must include the join field** — it is what correlates the fetched rows with the main ones.
|
|
477
|
-
- **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.
|
|
478
|
-
- **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.
|
|
479
|
-
- **Fail-closed** — a column added to the target later is not reachable through the relation until it is added to `fields`.
|
|
480
|
-
|
|
481
|
-
`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).
|
|
482
|
-
|
|
483
|
-
**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`.
|
|
484
|
-
|
|
485
|
-
**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.
|
|
486
|
-
|
|
487
|
-
```typescript
|
|
488
|
-
// on the parent (product) table:
|
|
489
|
-
allowedWriteJoins: [
|
|
490
|
-
buildRelation(SchemaProduct, 'id', SchemaProductInfo, 'productId', { alias: 'translations' }),
|
|
491
|
-
],
|
|
492
|
-
upsertMap: buildUpsertRules(
|
|
493
|
-
buildUpsertRule(SchemaProductInfo, ['productId', 'lang']), // composite conflict key
|
|
494
|
-
),
|
|
495
|
-
// → PUT /rest/product { "main": {...}, "secondaries": { "translations": [{ "lang": "en", "name": "Bike" }] } }
|
|
496
|
-
```
|
|
497
|
-
|
|
498
|
-
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.
|
|
499
|
-
|
|
500
|
-
**Aliasing the same table twice**: declare two `buildRelation` entries with different `alias`. Example: a `session` table referencing `user` for both `createdBy` and `updatedBy`:
|
|
501
|
-
|
|
502
|
-
```typescript
|
|
503
|
-
allowedReadJoins: [
|
|
504
|
-
buildRelation(SchemaSession, 'createdBy', SchemaUser, 'id', { alias: 'creator', unique: true }),
|
|
505
|
-
buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true }),
|
|
506
|
-
]
|
|
507
|
-
```
|
|
508
|
-
|
|
509
|
-
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.
|
|
510
|
-
|
|
511
|
-
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.
|
|
512
|
-
|
|
513
|
-
**`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.
|
|
514
|
-
|
|
515
|
-
### extraFilters + extendedCondition
|
|
516
|
-
|
|
517
|
-
For filters that don't map to real columns (e.g. full-text search `q`):
|
|
518
|
-
|
|
519
|
-
```typescript
|
|
520
|
-
import {defineTable, exportTableInfo, Type, ConditionBuilder} from 'fastify-auto-sqlapi';
|
|
521
|
-
import {SchemaCustomer as Schema} from '../schemas/SchemaCustomer';
|
|
522
|
-
|
|
523
|
-
const extraFiltersValidation = Type.Object({
|
|
524
|
-
q: Type.String(),
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
export const TableCustomer = defineTable({
|
|
528
|
-
primary: 'id',
|
|
529
|
-
...exportTableInfo(
|
|
530
|
-
Schema,
|
|
531
|
-
extraFiltersValidation,
|
|
532
|
-
// `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() }),
|
|
533
104
|
(condition, filters) => {
|
|
534
105
|
if (filters.q) {
|
|
535
106
|
const or = new ConditionBuilder('OR');
|
|
@@ -537,40 +108,40 @@ export const TableCustomer = defineTable({
|
|
|
537
108
|
or.isILike(Schema.col('email'), `%${filters.q}%`);
|
|
538
109
|
condition.append(or);
|
|
539
110
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
})
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
`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.
|
|
546
|
-
|
|
547
|
-
**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.
|
|
548
115
|
|
|
549
|
-
|
|
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
|
+
}],
|
|
550
122
|
|
|
551
|
-
---
|
|
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.
|
|
552
128
|
|
|
553
|
-
|
|
129
|
+
// --- joins (see Relations) ---
|
|
130
|
+
allowedReadJoins: [buildRelation(Schema, 'id', SchemaOrder, 'customerId', { alias: 'orders' })],
|
|
131
|
+
allowedWriteJoins: [buildRelation(Schema, 'id', SchemaOrder, 'customerId', { alias: 'orders' })],
|
|
554
132
|
|
|
555
|
-
|
|
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.
|
|
556
139
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
import { SchemaCustomer as Schema } from '../schemas/SchemaCustomer';
|
|
140
|
+
// --- tenant (see Tenant scope) ---
|
|
141
|
+
tenantScope: { column: 'organization_id' }, // REAL column name
|
|
560
142
|
|
|
561
|
-
|
|
562
|
-
primary: 'id',
|
|
563
|
-
...exportTableInfo(Schema),
|
|
143
|
+
// --- computed fields (see Computed fields) ---
|
|
564
144
|
computedFields: {
|
|
565
|
-
// JSON path extraction — dialect-aware (Postgres -> arrow, MySQL -> JSON_EXTRACT).
|
|
566
|
-
statusFromMeta: ({ db, qiCol }) => ({
|
|
567
|
-
expr: db.dialectName === 'postgres'
|
|
568
|
-
? `${qiCol('metadata')}->>'status'`
|
|
569
|
-
: `JSON_UNQUOTE(JSON_EXTRACT(${qiCol('metadata')}, '$.status'))`,
|
|
570
|
-
values: [],
|
|
571
|
-
type: Type.String(),
|
|
572
|
-
}),
|
|
573
|
-
// Derived string column — dialect-aware concat.
|
|
574
145
|
fullName: ({ db, qiCol }) => ({
|
|
575
146
|
expr: db.dialectName === 'postgres'
|
|
576
147
|
? `${qiCol('firstName')} || ' ' || ${qiCol('lastName')}`
|
|
@@ -579,872 +150,251 @@ export const TableCustomer = defineTable({
|
|
|
579
150
|
type: Type.String(),
|
|
580
151
|
}),
|
|
581
152
|
},
|
|
582
|
-
});
|
|
583
|
-
```
|
|
584
|
-
|
|
585
|
-
Client side, the computed name behaves like any other field:
|
|
586
|
-
|
|
587
|
-
```json
|
|
588
|
-
{
|
|
589
|
-
"filters": { "statusFromMeta": "active" },
|
|
590
|
-
"conditions": [
|
|
591
|
-
{ "field": "fullName", "method": "isLike", "params": ["%Mario%"] }
|
|
592
|
-
],
|
|
593
|
-
"orderBy": "fullName ASC",
|
|
594
|
-
"selectComputed": ["fullName", "statusFromMeta"]
|
|
595
|
-
}
|
|
596
|
-
```
|
|
597
|
-
|
|
598
|
-
The values returned by `selectComputed` appear as extra fields on each `main[i]` row.
|
|
599
|
-
|
|
600
|
-
### `ComputedFieldFn` signature
|
|
601
|
-
|
|
602
|
-
```typescript
|
|
603
|
-
type ComputedFieldFn = (ctx: {
|
|
604
|
-
db: QueryClient; // dialect-aware (qi, ph, dialectName, dateTrunc, ...)
|
|
605
|
-
qiCol(field: string, opts?: { qualifier?: string }): string;
|
|
606
|
-
}) => {
|
|
607
|
-
expr: string; // SQL fragment; mark each bound value with `?`
|
|
608
|
-
values: unknown[]; // one entry per `?` marker in expr
|
|
609
|
-
type: TSchema; // REQUIRED — used by Swagger and body validation
|
|
610
|
-
};
|
|
611
|
-
```
|
|
612
|
-
|
|
613
|
-
**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:
|
|
614
|
-
|
|
615
|
-
```typescript
|
|
616
|
-
bonus: ({ qiCol }) => ({
|
|
617
|
-
expr: `CASE WHEN ${qiCol('role')} = ? THEN ${qiCol('salary')} ELSE 0 END`,
|
|
618
|
-
values: ['admin'],
|
|
619
|
-
type: Type.Number(),
|
|
620
|
-
}),
|
|
621
|
-
```
|
|
622
|
-
|
|
623
|
-
`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.
|
|
624
|
-
|
|
625
|
-
### Validation & startup checks
|
|
626
|
-
|
|
627
|
-
`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.
|
|
628
|
-
|
|
629
|
-
### Side queries (`joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`)
|
|
630
|
-
|
|
631
|
-
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`.
|
|
632
|
-
|
|
633
|
-
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.
|
|
634
|
-
|
|
635
|
-
### Limitations (first round, by design)
|
|
636
|
-
|
|
637
|
-
- **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.
|
|
638
|
-
- 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.
|
|
639
|
-
- **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.
|
|
640
|
-
- **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.
|
|
641
|
-
- **No chained computed** (a computed referencing another computed). Flat-only.
|
|
642
|
-
- **Read-only**. Computed fields are not usable in insert/update bodies — the consumer's `expr` is for SELECT/WHERE/ORDER BY, never for writes.
|
|
643
|
-
- **Same expression evaluated multiple times** when used in WHERE + ORDER BY + SELECT. Cheap exprs are fine; for expensive ones, query planner CSE often helps.
|
|
644
|
-
|
|
645
|
-
---
|
|
646
|
-
|
|
647
|
-
## ConditionBuilder API
|
|
648
|
-
|
|
649
|
-
Used in `extendedCondition` callbacks, hooks, and exposed via the `conditions` field in the search API (methods and params arity in [AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md)).
|
|
650
|
-
|
|
651
|
-
```typescript
|
|
652
|
-
const cb = new ConditionBuilder('AND'); // or 'OR'
|
|
653
|
-
cb.isEqual('column', value);
|
|
654
|
-
cb.isNotEqual('column', value);
|
|
655
|
-
cb.isGreater('column', value);
|
|
656
|
-
cb.isGreaterOrEqual('column', value);
|
|
657
|
-
cb.isLess('column', value);
|
|
658
|
-
cb.isLessOrEqual('column', value);
|
|
659
|
-
cb.isLike('column', '%value%');
|
|
660
|
-
cb.isILike('column', '%value%'); // case-insensitive LIKE
|
|
661
|
-
cb.isBetween('column', from, to);
|
|
662
|
-
cb.isIn('column', [val1, val2]);
|
|
663
|
-
cb.isNotIn('column', [val1, val2]);
|
|
664
|
-
cb.isNull('column', true);
|
|
665
|
-
cb.isNotNull('column', true);
|
|
666
|
-
cb.raw('column::text = $1', [value]); // raw SQL with params (backend only, NOT exposed in API)
|
|
667
|
-
cb.append(otherConditionBuilder); // nest conditions (backend only, NOT exposed in API)
|
|
668
|
-
```
|
|
669
|
-
|
|
670
|
-
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.
|
|
671
|
-
|
|
672
|
-
All values are parameterized (`$1, $2, ...`), never interpolated.
|
|
673
|
-
|
|
674
|
-
---
|
|
675
|
-
|
|
676
|
-
## SqlApi — Programmatic High-Level API
|
|
677
|
-
|
|
678
|
-
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.
|
|
679
|
-
|
|
680
|
-
It takes and returns **camelCase schema field names**, exactly like the HTTP bodies documented in
|
|
681
|
-
[AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md) — `sqlApi.search('order', { filters: { customerId: 3 } })`,
|
|
682
|
-
never `customer_id`. Hooks, validation, computed fields and tenant scoping all run, so this is the
|
|
683
|
-
right entry point for application code. Drop to [`QueryClient`](#queryclient-api) only for SQL that
|
|
684
|
-
has no equivalent here — it is the layer underneath, with none of that behavior and with real DB
|
|
685
|
-
column names.
|
|
686
|
-
|
|
687
|
-
### Using `app.sqlApi` (recommended)
|
|
688
|
-
|
|
689
|
-
After registering the plugin, `app.sqlApi` is available everywhere — no extra configuration needed:
|
|
690
|
-
|
|
691
|
-
```typescript
|
|
692
|
-
await app.register(fastifyAutoSqlApi, {
|
|
693
|
-
DbTables: dbTables,
|
|
694
|
-
dialect: 'mysql',
|
|
695
|
-
swagger: true,
|
|
696
|
-
prefix: '/auto',
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
// app.sqlApi is available in any route — even outside the plugin scope
|
|
700
|
-
app.get('/billing/:customerId', async (request) => {
|
|
701
|
-
const customerId = Number(request.params.customerId);
|
|
702
|
-
return app.sqlApi.search('subscription', {
|
|
703
|
-
filters: { customerId, status: 'active' },
|
|
704
|
-
joinMustExist: { orders: { filters: { status: 'pending' } } },
|
|
705
|
-
paginator: { page: 1, itemsPerPage: 50 },
|
|
706
|
-
}, request);
|
|
707
|
-
});
|
|
708
|
-
```
|
|
709
|
-
|
|
710
|
-
`app.sqlApi` inherits all configuration from the plugin registration (dialect, DbTables, tenant, debug). No need to pass them again.
|
|
711
|
-
|
|
712
|
-
### Using `createSqlApi` (standalone — for background jobs or without the plugin)
|
|
713
|
-
|
|
714
|
-
When you need a `SqlApi` instance without registering the full plugin (e.g. background jobs, scripts, tests):
|
|
715
|
-
|
|
716
|
-
```typescript
|
|
717
|
-
import { createSqlApi, mysqlQueryable } from 'fastify-auto-sqlapi';
|
|
718
|
-
|
|
719
|
-
// Option 1: pass a raw pool — SqlApi creates the QueryClient internally
|
|
720
|
-
const sqlApi = createSqlApi(mysqlQueryable(pool), dbTables, { dialect: 'mysql' });
|
|
721
|
-
|
|
722
|
-
// Option 2: pass a pre-built QueryClient
|
|
723
|
-
import { createQueryClient } from 'fastify-auto-sqlapi';
|
|
724
|
-
const db = createQueryClient(mysqlQueryable(pool), 'mysql');
|
|
725
|
-
const sqlApi = createSqlApi(db, dbTables, { dialect: 'mysql' });
|
|
726
|
-
```
|
|
727
|
-
|
|
728
|
-
**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.
|
|
729
|
-
|
|
730
|
-
### Available methods
|
|
731
|
-
|
|
732
|
-
```typescript
|
|
733
|
-
// Search — full filter, join, pagination, aggregation support
|
|
734
|
-
// orderBy supports two dotted notations:
|
|
735
|
-
// - 3-part `<alias>.<fn>.<field>` for joinGroup aggregations (must be declared in the request body)
|
|
736
|
-
// - 2-part `<alias>.<field>` for joinLeft parent fields
|
|
737
|
-
// See AGENTS_FRONTEND.md and BREAKING_CHANGES.md for the full reference.
|
|
738
|
-
sqlApi.search(tableName, {
|
|
739
|
-
filters?, conditions?,
|
|
740
|
-
joinMustExist?, joinMultiple?, joinGroup?, joinLeft?,
|
|
741
|
-
orderBy?, paginator?, computeMin?, computeMax?, computeSum?, computeAvg?,
|
|
742
|
-
}, request?): Promise<SearchResult>
|
|
743
|
-
|
|
744
|
-
// Get single record by PK
|
|
745
|
-
sqlApi.get(tableName, id, request?): Promise<GetResult>
|
|
746
|
-
|
|
747
|
-
// Insert — with optional secondaries, hooks, tenant
|
|
748
|
-
sqlApi.insert(tableName, { record, secondaries? }, request?): Promise<InsertResult>
|
|
749
|
-
|
|
750
|
-
// Update — with optional secondaries, deletions, hooks, tenant
|
|
751
|
-
sqlApi.update(tableName, { record, secondaries?, deletions? }, request?): Promise<UpdateResult>
|
|
752
|
-
|
|
753
|
-
// Delete by PK
|
|
754
|
-
sqlApi.delete(tableName, id, request?): Promise<DeleteResult>
|
|
755
|
-
|
|
756
|
-
// Bulk upsert
|
|
757
|
-
sqlApi.bulkUpsert(tableName, items, request?): Promise<BulkUpsertResult[]>
|
|
758
|
-
|
|
759
|
-
// Bulk delete
|
|
760
|
-
sqlApi.bulkDelete(tableName, ids, request?): Promise<BulkDeleteResult[]>
|
|
761
|
-
```
|
|
762
|
-
|
|
763
|
-
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.
|
|
764
|
-
|
|
765
|
-
---
|
|
766
|
-
|
|
767
|
-
## QueryClient API
|
|
768
|
-
|
|
769
|
-
The SQL layer **underneath** [`SqlApi`](#sqlapi--programmatic-high-level-api): it executes what you
|
|
770
|
-
give it and nothing else — no hooks, no validation, no computed fields, no tenant scoping. Reach for
|
|
771
|
-
it only when the operation has no `sqlApi` equivalent; anything expressed as CRUD belongs there.
|
|
772
|
-
|
|
773
|
-
It speaks the **database's own names**: real table names (`Schema.tableName`, not the schema
|
|
774
|
-
variable) and real column names — get them from `Schema.col('customerId')` rather than assuming a
|
|
775
|
-
convention, since the columns may be snake_case or already camelCase. Passing a schema field name
|
|
776
|
-
the mapping would have translated produces a SQL error at runtime, not a validation error.
|
|
777
|
-
|
|
778
|
-
Available in hooks via the `db` parameter:
|
|
779
|
-
|
|
780
|
-
```typescript
|
|
781
|
-
db.insert(tableName, record, pkCol); // INSERT, returns PK row
|
|
782
|
-
db.insertOrUpdate(tableName, record, conflictKeys, pkCol); // INSERT ON CONFLICT, returns PK row
|
|
783
|
-
db.bulkInsert(tableName, records, pkCol, chunkSize?); // Multi-row INSERT, returns PK rows
|
|
784
|
-
db.bulkInsertOrUpdate(tableName, records, conflictKeys, pkCol, chunkSize?); // Multi-row UPSERT, returns PK rows
|
|
785
|
-
db.update(tableName, record, where, extraCondition?); // UPDATE, returns affectedRows (number)
|
|
786
|
-
db.delete(tableName, where); // DELETE, returns affectedRows (number)
|
|
787
|
-
db.select({ tableName, columns?, where, values, limit?, orderBy?, joins?, distinct? }); // SELECT, returns rows
|
|
788
|
-
db.query(sql, values); // Raw query
|
|
789
|
-
db.expression(value); // Raw SQL expression (not parameterized)
|
|
790
|
-
```
|
|
791
|
-
|
|
792
|
-
`pkCol` can be a string (`'id'`) or an array (`['agent_id', 'team_id']`) for composite PKs. PostgreSQL/MariaDB use `RETURNING`, MySQL uses `insertId`.
|
|
793
|
-
|
|
794
|
-
Record keys are real column names (`Schema.col('field')`). Values are parameterized.
|
|
795
|
-
|
|
796
|
-
---
|
|
797
|
-
|
|
798
|
-
## Swagger
|
|
799
|
-
|
|
800
|
-
```typescript
|
|
801
|
-
import { setupSwagger } from 'fastify-auto-sqlapi';
|
|
802
|
-
|
|
803
|
-
// Basic (UI at /documentation)
|
|
804
|
-
await setupSwagger(instance, { swagger: true });
|
|
805
|
-
|
|
806
|
-
// Custom
|
|
807
|
-
await setupSwagger(instance, {
|
|
808
|
-
swagger: {
|
|
809
|
-
title: 'My API',
|
|
810
|
-
description: 'Auto-generated CRUD API',
|
|
811
|
-
version: '1.0.0',
|
|
812
|
-
routePrefix: '/docs',
|
|
813
|
-
},
|
|
814
|
-
});
|
|
815
|
-
```
|
|
816
|
-
|
|
817
|
-
Requires `@fastify/swagger` and `@fastify/swagger-ui` as peer deps. If not installed, logs a warning and continues.
|
|
818
|
-
|
|
819
|
-
---
|
|
820
|
-
|
|
821
|
-
## Multi-Tenant Filtering
|
|
822
|
-
|
|
823
|
-
Automatic row-level isolation per tenant on all CRUD operations. Zero code in route handlers — just configure and go.
|
|
824
|
-
|
|
825
|
-
### Setup
|
|
826
|
-
|
|
827
|
-
```typescript
|
|
828
|
-
import { fastifyAutoSqlApi } from 'fastify-auto-sqlapi';
|
|
829
|
-
|
|
830
|
-
await app.register(fastifyAutoSqlApi, {
|
|
831
|
-
DbTables: dbTables,
|
|
832
|
-
// Return tenant ID(s) from request. null/undefined = admin (no filter).
|
|
833
|
-
getTenantId: (request) => {
|
|
834
|
-
const orgId = request.user?.organizationId;
|
|
835
|
-
return orgId ?? null;
|
|
836
|
-
},
|
|
837
|
-
});
|
|
838
|
-
```
|
|
839
|
-
|
|
840
|
-
Every column named in a `tenantScope` — `column`, the entries of `anyOf`, `through.localField`
|
|
841
|
-
and `through.foreignField` — is a **real DB column name**, not a camelCase schema field. This is
|
|
842
|
-
the one key of `defineTable` where the convention flips, and it is deliberate: a scope is applied
|
|
843
|
-
to the SQL, *after* the payload has been converted, and `through.foreignField` belongs to a parent
|
|
844
|
-
table that may have no config, no `colMap`, or no generated schema at all. Same category as `db.*`
|
|
845
|
-
and `extendedCondition` — names that go straight to the database.
|
|
846
|
-
|
|
847
|
-
Write them as the table declares them (`organization_id` below, but `organizationId` on a
|
|
848
|
-
camelCase table); clients keep sending the camelCase field name either way, including when
|
|
849
|
-
anchoring an `anyOf` write.
|
|
850
|
-
|
|
851
|
-
`defineTable` catches the mistake this exception invites: naming the schema **field** whose real
|
|
852
|
-
column is something else (`organizationId` where the column is `organization_id`) throws at
|
|
853
|
-
startup, on all four positions, checking `through.foreignField` against the parent schema. It
|
|
854
|
-
cannot check that a column *exists* — a hand-written schema may legitimately omit it — but it can
|
|
855
|
-
tell you that you named a field. Without it the mistake surfaces as
|
|
856
|
-
`column "organizationId" does not exist` on every request to the table: an opaque `500` that fails
|
|
857
|
-
closed and says nothing. On a camelCase database the check raises nothing, since `col()` is the
|
|
858
|
-
identity there.
|
|
859
|
-
|
|
860
|
-
### Direct tenant (column on the table itself)
|
|
861
|
-
|
|
862
|
-
```typescript
|
|
863
|
-
const TableCustomer = defineTable({
|
|
864
|
-
primary: 'id',
|
|
865
|
-
...exportTableInfo(SchemaCustomer),
|
|
866
|
-
tenantScope: { column: 'organization_id' },
|
|
867
|
-
});
|
|
868
|
-
```
|
|
869
|
-
|
|
870
|
-
Behavior:
|
|
871
|
-
- **Read** (search, get, delete, bulk-delete): adds `AND organization_id IN ($N)` to WHERE
|
|
872
|
-
- **Insert**: auto-injects `organization_id` if single tenant; validates if already present (403 if mismatch); 400 if multi-tenant without explicit value
|
|
873
|
-
- **Update**: strips `organization_id` from SET (can't change tenant), adds to WHERE condition
|
|
874
|
-
- **Bulk upsert**: auto-injects on all records
|
|
875
|
-
- **Upsert conflict guard** (`upsertMap` set): before the `ON CONFLICT DO UPDATE`, a probe rejects (403) any incoming conflict key that matches a row owned by another tenant — an upsert cannot overwrite or re-assign a foreign tenant's row.
|
|
876
|
-
|
|
877
|
-
### Indirect tenant (via JOIN to parent table)
|
|
878
|
-
|
|
879
|
-
```typescript
|
|
880
|
-
const TableOrder = defineTable({
|
|
881
|
-
primary: 'id',
|
|
882
|
-
...exportTableInfo(SchemaOrder),
|
|
883
|
-
tenantScope: {
|
|
884
|
-
column: 'organization_id',
|
|
885
|
-
through: {
|
|
886
|
-
schema: SchemaCustomer, // parent table schema
|
|
887
|
-
localField: 'customer_id', // FK on this table
|
|
888
|
-
foreignField: 'id', // PK on parent table
|
|
889
|
-
},
|
|
890
|
-
},
|
|
891
|
-
});
|
|
892
|
-
```
|
|
893
|
-
|
|
894
|
-
Behavior:
|
|
895
|
-
- **Read**: INNER JOIN to parent table + WHERE on parent's tenant column
|
|
896
|
-
- **Insert/Bulk upsert**: validates FK references belong to tenant (1 batch query)
|
|
897
|
-
- **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).
|
|
898
|
-
- **Delete**: subquery `DELETE WHERE pk IN (SELECT ... INNER JOIN ... WHERE tenant IN (...))`
|
|
899
|
-
|
|
900
|
-
### Shared tenant (`anyOf`: several owner columns)
|
|
901
|
-
|
|
902
|
-
For a row owned by two parties and visible to either — a message (`sender_id` / `recipient_id`),
|
|
903
|
-
a transfer (`from_account_id` / `to_account_id`), a shift swap:
|
|
904
|
-
|
|
905
|
-
```typescript
|
|
906
|
-
const TableShiftSwapRequest = defineTable({
|
|
907
|
-
primary: 'id',
|
|
908
|
-
...exportTableInfo(SchemaShiftSwapRequest),
|
|
909
|
-
tenantScope: { anyOf: ['requester_agent_id', 'target_agent_id'] },
|
|
910
|
-
});
|
|
911
|
-
```
|
|
912
|
-
|
|
913
|
-
Behavior:
|
|
914
|
-
- **Read** (search, get, delete, bulk-delete): adds
|
|
915
|
-
`AND (requester_agent_id IN ($N) OR target_agent_id IN ($M))`. A `NULL` column does not match
|
|
916
|
-
and does not stop the other column from matching. A row with every listed column `NULL` is
|
|
917
|
-
visible to nobody but an admin.
|
|
918
|
-
- **Joins**: the predicate crosses every join family — `joinMultiple`, `joinGroup`,
|
|
919
|
-
`joinMustExist`, and `joinLeft` (in the `ON` clause, alias-qualified, so `LEFT` semantics
|
|
920
|
-
survive) — exactly as a single-column scope does, including from a host table that declares no
|
|
921
|
-
scope of its own.
|
|
922
|
-
- **Insert**: no auto-injection. The payload must **anchor** the row: at least one listed column
|
|
923
|
-
present and holding a tenant id → `400` if none is present, `403` if present but all foreign.
|
|
924
|
-
The other parties are stored exactly as sent. A multi-id caller is never ambiguous here,
|
|
925
|
-
because nothing is being guessed.
|
|
926
|
-
- **Update**: strips **every** listed column from the `SET` — neither party can be re-assigned —
|
|
927
|
-
and adds the OR predicate to the `WHERE`. A row outside the scope answers `404`.
|
|
928
|
-
- **Bulk upsert**: the anchor rule applies to every item (so an upsert must name a party even
|
|
929
|
-
when it only means to update), and the listed columns are excluded from the `DO UPDATE SET`,
|
|
930
|
-
so an upsert onto an existing row cannot take over the other party's slot. The conflict guard
|
|
931
|
-
rejects (`403`) a conflict key matching a row visible to neither party — including a row whose
|
|
932
|
-
parties are `NULL`.
|
|
933
|
-
- **Secondaries**: a table with an `anyOf` scope written through another table's
|
|
934
|
-
`allowedWriteJoins` follows the same anchor rule. The parent's FK is auto-filled but is not an
|
|
935
|
-
owner, so the child payload must still name a party.
|
|
936
|
-
|
|
937
|
-
Not combinable with `column` or `through` — `defineTable` throws at startup. An `anyOf` entry
|
|
938
|
-
reached through a parent FK is not supported; declare the scope on the parent instead.
|
|
939
|
-
|
|
940
|
-
### Multi-tenant users
|
|
941
|
-
|
|
942
|
-
```typescript
|
|
943
|
-
getTenantId: (request) => {
|
|
944
|
-
// Return array for users managing multiple tenants
|
|
945
|
-
return request.user?.organizationIds ?? null; // e.g. [1, 2, 3]
|
|
946
|
-
},
|
|
947
|
-
```
|
|
948
|
-
|
|
949
|
-
Uses `IN ($N, $M, ...)` for all WHERE clauses (same as single tenant, just with more values).
|
|
950
|
-
|
|
951
|
-
### Admin bypass
|
|
952
|
-
|
|
953
|
-
When `getTenantId` returns `null` or `undefined`, no filtering is applied (full access).
|
|
954
|
-
|
|
955
|
-
### Tables without tenant
|
|
956
|
-
|
|
957
|
-
Tables without `tenantScope` are unaffected — no filtering regardless of `getTenantId` result.
|
|
958
|
-
|
|
959
|
-
### Error codes
|
|
960
|
-
|
|
961
|
-
- **403** — Record doesn't belong to tenant, or explicit tenant value doesn't match
|
|
962
|
-
- **400** — Multi-tenant user on insert without explicit tenant value (ambiguous), or an
|
|
963
|
-
`anyOf` write naming no owner column at all
|
|
964
|
-
- **404** — Update with indirect tenant, record not found for this tenant; get/update/delete of
|
|
965
|
-
an `anyOf`-scoped row outside the scope
|
|
966
|
-
|
|
967
|
-
---
|
|
968
|
-
|
|
969
|
-
## Views
|
|
970
|
-
|
|
971
|
-
A view is a **table config** like any other — same `defineTable`, same `Table*.ts` file, same
|
|
972
|
-
routes. There is no `View*` anything: searching, filtering, ordering, pagination, joins and
|
|
973
|
-
computed fields all work through the same code path as a base table. Materialized views are
|
|
974
|
-
included, PostgreSQL ones read from `pg_catalog` since `information_schema` does not describe
|
|
975
|
-
them.
|
|
976
|
-
|
|
977
|
-
What differs is what the generator can read from the database, and it is worth knowing before
|
|
978
|
-
you accept the file it writes.
|
|
979
|
-
|
|
980
|
-
**The primary key is not declared.** A view carries no `PRIMARY KEY` constraint, so
|
|
981
|
-
`sqlapi-generate-schema` has nothing to report and the table generator **will not guess one
|
|
982
|
-
from the column types** — on `SELECT status, count(*) AS order_count ... GROUP BY status` that
|
|
983
|
-
would take the count as the key, and `get/:id`, `update` and `delete` would then address rows
|
|
984
|
-
by it. Instead:
|
|
985
|
-
|
|
986
|
-
- the view has an `id` column → it is used, and the file says the key was *inferred, not read
|
|
987
|
-
from the database*. Check that it is really unique in the view before exposing
|
|
988
|
-
`get`/`update`/`delete`.
|
|
989
|
-
- no plausible column → `primary: 'TODO_pick_a_unique_column'` plus `operations: ['search']`
|
|
990
|
-
and a real `defaultOrder`. **This config works as generated**: search reads `defaultOrder`,
|
|
991
|
-
never the primary key. The TODO is what you replace to widen `operations`, not a defect.
|
|
992
|
-
|
|
993
|
-
`defineTable` enforces that: a `primary` naming a field the schema does not have throws **at
|
|
994
|
-
startup**, unless the table exposes only `search` and declares its own `defaultOrder`.
|
|
995
|
-
|
|
996
|
-
**Writes start off, but are not forbidden.** A view's generated config is
|
|
997
|
-
`operations: ['search', 'get']`. An aggregating view rejects every INSERT and UPDATE at the
|
|
998
|
-
database (`cannot insert into view ...`, a raw `500`), while a view simple enough for the engine
|
|
999
|
-
to make updatable accepts them on both PostgreSQL and MySQL — using one as a projection or
|
|
1000
|
-
permission layer is a supported pattern. The read-only start is the *generated template*, not a
|
|
1001
|
-
runtime rule: remove or widen `operations` and the write routes come back.
|
|
1002
|
-
|
|
1003
|
-
**Nullability differs between engines** for the same view: PostgreSQL reports every view column
|
|
1004
|
-
as nullable, MySQL reports the real nullability of the underlying expression. The generated
|
|
1005
|
-
Schema therefore differs by dialect for one and the same view. It affects write bodies only, and
|
|
1006
|
-
a view is not usually written to.
|
|
1007
|
-
|
|
1008
|
-
`sqlapi-generate-schema` prints the views it found and both assumptions above — read that
|
|
1009
|
-
output, it is the part that does not depend on anyone opening the generated file. See
|
|
1010
|
-
[ADR 0016](./docs/adr/0016-a-view-is-a-table-config.md).
|
|
1011
|
-
|
|
1012
|
-
---
|
|
1013
|
-
|
|
1014
|
-
## Key Conventions
|
|
1015
|
-
|
|
1016
|
-
- **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()`.
|
|
1017
|
-
- **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
|
|
1018
|
-
- **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`.
|
|
1019
|
-
- **`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`.
|
|
1020
|
-
- **`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).
|
|
1021
|
-
- **`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).
|
|
1022
|
-
- **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.
|
|
1023
|
-
- **`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.
|
|
1024
|
-
- **`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.
|
|
1025
|
-
- **`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.
|
|
1026
|
-
- **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.
|
|
1027
|
-
- **`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.
|
|
1028
|
-
- **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.
|
|
1029
|
-
- **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).
|
|
1030
|
-
- **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 } } })`.
|
|
1031
|
-
- **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.
|
|
1032
|
-
- **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.
|
|
1033
|
-
- **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.
|
|
1034
|
-
- **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`).
|
|
1035
|
-
|
|
1036
|
-
---
|
|
1037
|
-
|
|
1038
|
-
## Common Backend Patterns
|
|
1039
|
-
|
|
1040
|
-
### Auth middleware (global)
|
|
1041
|
-
|
|
1042
|
-
```typescript
|
|
1043
|
-
const opts = {
|
|
1044
|
-
DbTables: dbTables,
|
|
1045
|
-
onRequests: [async (req, reply) => {
|
|
1046
|
-
if (!req.headers.authorization) return reply.status(401).send({ error: 'Unauthorized' });
|
|
1047
|
-
}],
|
|
1048
|
-
};
|
|
1049
|
-
```
|
|
1050
|
-
|
|
1051
|
-
### Auth middleware (per-table)
|
|
1052
|
-
|
|
1053
|
-
```typescript
|
|
1054
|
-
const TableAdmin = defineTable({
|
|
1055
|
-
primary: 'id',
|
|
1056
|
-
...exportTableInfo(SchemaAdmin),
|
|
1057
|
-
onRequests: [async (req, reply) => {
|
|
1058
|
-
if (req.user.role !== 'admin') return reply.status(403).send({ error: 'Forbidden' });
|
|
1059
|
-
}],
|
|
1060
|
-
});
|
|
1061
|
-
```
|
|
1062
|
-
|
|
1063
|
-
### Custom validation (field-level, cross-entity)
|
|
1064
|
-
|
|
1065
|
-
```typescript
|
|
1066
|
-
import type { ValidationError } from 'fastify-auto-sqlapi';
|
|
1067
|
-
|
|
1068
|
-
const TableSession = defineTable({
|
|
1069
|
-
primary: 'id',
|
|
1070
|
-
...exportTableInfo(SchemaSession),
|
|
1071
|
-
allowedWriteJoins: [
|
|
1072
|
-
buildRelation(SchemaSession, 'id', SchemaPeriod, 'sessionId', { alias: 'periods' }),
|
|
1073
|
-
],
|
|
1074
|
-
validate: async (db, req, main, secondaries) => {
|
|
1075
|
-
// ValidationError is a tuple: [field, code] or [field, code, message]
|
|
1076
|
-
// message defaults to code if omitted
|
|
1077
|
-
const errors: ValidationError[] = [];
|
|
1078
|
-
|
|
1079
|
-
// Simple field validation
|
|
1080
|
-
if (!main.name) {
|
|
1081
|
-
errors.push(['name', 'required']); // message defaults to 'required'
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
// Async validation (uniqueness check)
|
|
1085
|
-
if (main.code) {
|
|
1086
|
-
const existing = await db.query('SELECT 1 FROM session WHERE code = $1 AND id != $2', [main.code, main.id ?? 0]);
|
|
1087
|
-
if (existing.rows.length) {
|
|
1088
|
-
errors.push(['code', 'unique', 'already exists']);
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
// Cross-entity: validate periods don't overlap (secondaries keyed by alias)
|
|
1093
|
-
const periods = secondaries?.periods;
|
|
1094
|
-
if (periods?.length) {
|
|
1095
|
-
for (let i = 0; i < periods.length; i++) {
|
|
1096
|
-
for (let j = i + 1; j < periods.length; j++) {
|
|
1097
|
-
if (periods[i].startDate < periods[j].endDate && periods[j].startDate < periods[i].endDate) {
|
|
1098
|
-
errors.push([`periods[${j}].startDate`, 'overlap', 'overlaps with another period']);
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
153
|
|
|
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']);
|
|
1104
160
|
return errors;
|
|
1105
161
|
},
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
`validate` runs on insert, update, and each item in bulk-upsert (when `validateBulk` is not defined). When `validateBulk` is defined, it **replaces** per-item `validate` in bulk operations — use it for optimized batch queries and cross-item validation:
|
|
1110
|
-
|
|
1111
|
-
```typescript
|
|
1112
|
-
const TableSession = defineTable({
|
|
1113
|
-
// ...
|
|
1114
|
-
validateBulk: async (db, req, items) => {
|
|
1115
|
-
// items: Array<{ main, secondaries? }>
|
|
1116
|
-
// Check for duplicate codes across all items in the batch
|
|
1117
|
-
const codes = items.map(i => i.main.code).filter(Boolean);
|
|
1118
|
-
const unique = new Set(codes);
|
|
1119
|
-
if (unique.size !== codes.length) {
|
|
1120
|
-
return [['code', 'batch_unique', 'duplicate codes in batch']];
|
|
1121
|
-
}
|
|
1122
|
-
return [];
|
|
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']];
|
|
1123
165
|
},
|
|
1124
|
-
});
|
|
1125
|
-
```
|
|
1126
|
-
|
|
1127
|
-
### Auto-fill audit fields
|
|
1128
166
|
|
|
1129
|
-
|
|
1130
|
-
const TableCustomer = defineTable({
|
|
1131
|
-
primary: 'id',
|
|
1132
|
-
...exportTableInfo(SchemaCustomer),
|
|
167
|
+
// --- hooks: camelCase records, mutations reach the SQL ---
|
|
1133
168
|
beforeInsert: async (db, req, record) => {
|
|
1134
|
-
// camelCase — schema field names. The plugin converts to the actual DB column names.
|
|
1135
169
|
record.createdBy = req.user.id;
|
|
1136
|
-
record.createdAt = db.expression('NOW()');
|
|
170
|
+
record.createdAt = db.expression('NOW()'); // raw SQL value
|
|
1137
171
|
},
|
|
1138
|
-
beforeUpdate: async (db, req, fields) => {
|
|
1139
|
-
|
|
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
|
|
1140
174
|
fields.updatedAt = db.expression('NOW()');
|
|
1141
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);
|
|
184
|
+
},
|
|
1142
185
|
});
|
|
1143
186
|
```
|
|
1144
187
|
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
The `pg` driver — not the plugin — returns `numeric`/`int8` as **strings** (precision
|
|
1148
|
-
safety), parses `date` into a JS `Date` at local midnight, and interprets
|
|
1149
|
-
`timestamp without time zone` in the server's local timezone. The plugin deliberately does
|
|
1150
|
-
not touch `pg.types` (global process state — see the design principles). If your API should
|
|
1151
|
-
return numbers and stable date strings, configure the parsers once at startup, before
|
|
1152
|
-
creating the pool:
|
|
1153
|
-
|
|
1154
|
-
```typescript
|
|
1155
|
-
import pg from 'pg';
|
|
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`.
|
|
1156
189
|
|
|
1157
|
-
|
|
1158
|
-
pg.types.setTypeParser(pg.types.builtins.NUMERIC, (v) => parseFloat(v));
|
|
1159
|
-
// Keep dates/timestamps as verbatim strings (no local-timezone Date conversion).
|
|
1160
|
-
// Recommended when the DB stores UTC in `timestamp without time zone` (several ERPs do).
|
|
1161
|
-
pg.types.setTypeParser(pg.types.builtins.DATE, (v) => v);
|
|
1162
|
-
pg.types.setTypeParser(pg.types.builtins.TIMESTAMP, (v) => v);
|
|
1163
|
-
```
|
|
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.
|
|
1164
191
|
|
|
1165
|
-
|
|
1166
|
-
schema says integer — the parsers matter for programmatic `sqlApi.*` results, arithmetic in
|
|
1167
|
-
hooks, and timestamp correctness.
|
|
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.
|
|
1168
193
|
|
|
1169
|
-
|
|
194
|
+
## Relations
|
|
1170
195
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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.
|
|
202
|
+
});
|
|
1174
203
|
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
+
],
|
|
208
|
+
```
|
|
1177
209
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
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
|
+
}),
|
|
238
|
+
},
|
|
1190
239
|
```
|
|
1191
240
|
|
|
1192
|
-
|
|
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.
|
|
1193
245
|
|
|
1194
|
-
|
|
1195
|
-
(see `excludeFromCreation` in Key Conventions). Encode yours in the hooks:
|
|
246
|
+
## afterRead
|
|
1196
247
|
|
|
1197
|
-
|
|
1198
|
-
const TableUser = defineTable({
|
|
1199
|
-
primary: 'id',
|
|
1200
|
-
...exportTableInfo(SchemaUser),
|
|
1201
|
-
// Silent strip: non-admins simply cannot touch the flag
|
|
1202
|
-
beforeUpdate: async (db, req, fields) => {
|
|
1203
|
-
if (!req.user.isAdmin) delete fields.isAdmin;
|
|
1204
|
-
},
|
|
1205
|
-
// Or loud 400 via validate:
|
|
1206
|
-
// validate: async (db, req, main) => {
|
|
1207
|
-
// if (main.isAdmin !== undefined && !req.user.isAdmin)
|
|
1208
|
-
// return [['isAdmin', 'forbidden']];
|
|
1209
|
-
// return [];
|
|
1210
|
-
// },
|
|
1211
|
-
});
|
|
1212
|
-
```
|
|
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.
|
|
1213
249
|
|
|
1214
|
-
|
|
1215
|
-
|
|
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.
|
|
1216
254
|
|
|
1217
|
-
|
|
255
|
+
## Tenant scope
|
|
1218
256
|
|
|
1219
|
-
|
|
1220
|
-
hook — and nothing at the call sites:
|
|
257
|
+
`getTenantId(req)` returns one of:
|
|
1221
258
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
|
1226
263
|
|
|
1227
|
-
|
|
1228
|
-
if (record.ssn != null) record.ssn = encrypt(record.ssn);
|
|
1229
|
-
},
|
|
1230
|
-
beforeUpdate: async (db, req, fields) => {
|
|
1231
|
-
if (fields.ssn != null) fields.ssn = encrypt(fields.ssn);
|
|
1232
|
-
},
|
|
1233
|
-
afterRead: async (db, req, rows) => {
|
|
1234
|
-
for (const row of rows) {
|
|
1235
|
-
if (row.ssn != null) row.ssn = decrypt(row.ssn);
|
|
1236
|
-
}
|
|
1237
|
-
},
|
|
1238
|
-
});
|
|
1239
|
-
```
|
|
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.
|
|
1240
265
|
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
What the plugin does *not* do for you, because it cannot know your cipher:
|
|
1248
|
-
|
|
1249
|
-
- **searching**: `filters: { ssn: '123-45' }` compares the plaintext against the ciphertext in
|
|
1250
|
-
SQL and matches nothing. With a deterministic cipher, encrypt the value yourself in
|
|
1251
|
-
`extendedCondition`; with a randomized one, that column is not searchable at all. `orderBy`
|
|
1252
|
-
and `computeMin/Max` sort and aggregate the ciphertext.
|
|
1253
|
-
- **typing**: the hook transforms values, not types. The response is serialized against the
|
|
1254
|
-
generated schema, so returning an object where the column is `text` yields
|
|
1255
|
-
`"[object Object]"`. `text`→`text` and `numeric`→`numeric` transforms are the supported shape.
|
|
1256
|
-
- **aggregates**: `joinGroup` and `compute*` return values the hook never sees.
|
|
1257
|
-
|
|
1258
|
-
### Full-text search filter
|
|
1259
|
-
|
|
1260
|
-
```typescript
|
|
1261
|
-
const extraFilters = Type.Object({ q: Type.String() });
|
|
1262
|
-
|
|
1263
|
-
...exportTableInfo(Schema, extraFilters, (condition, filters) => {
|
|
1264
|
-
if (filters.q) {
|
|
1265
|
-
const or = new ConditionBuilder('OR');
|
|
1266
|
-
or.isILike(Schema.col('name'), `%${filters.q}%`);
|
|
1267
|
-
or.isILike(Schema.col('email'), `%${filters.q}%`);
|
|
1268
|
-
or.isILike(Schema.col('phoneNumber'), `%${filters.q}%`);
|
|
1269
|
-
condition.append(or);
|
|
1270
|
-
}
|
|
1271
|
-
}),
|
|
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
|
|
1272
271
|
```
|
|
1273
272
|
|
|
1274
|
-
|
|
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.
|
|
1275
274
|
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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) |
|
|
1281
282
|
|
|
1282
|
-
|
|
1283
|
-
if (filters.dateFrom && filters.dateTo) {
|
|
1284
|
-
condition.isBetween(Schema.col('orderDate'), filters.dateFrom, filters.dateTo);
|
|
1285
|
-
} else if (filters.dateFrom) {
|
|
1286
|
-
condition.isGreater(Schema.col('orderDate'), filters.dateFrom);
|
|
1287
|
-
}
|
|
1288
|
-
}),
|
|
1289
|
-
```
|
|
283
|
+
`anyOf` children written as secondaries follow the anchor rule: the auto-filled parent FK is not an owner.
|
|
1290
284
|
|
|
1291
|
-
|
|
285
|
+
## sqlApi (application code)
|
|
1292
286
|
|
|
1293
|
-
|
|
1294
|
-
await app.register(fastifyAutoSqlApi, {
|
|
1295
|
-
DbTables: dbTables,
|
|
1296
|
-
getTenantId: (request) => {
|
|
1297
|
-
// From JWT claims
|
|
1298
|
-
return request.user?.organizationId ?? null;
|
|
1299
|
-
|
|
1300
|
-
// Or from header (multi-tenant support)
|
|
1301
|
-
// const header = request.headers['x-tenant-id'] as string | undefined;
|
|
1302
|
-
// if (!header) return null;
|
|
1303
|
-
// const ids = header.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
|
|
1304
|
-
// return ids.length === 0 ? null : ids.length === 1 ? ids[0] : ids;
|
|
1305
|
-
},
|
|
1306
|
-
});
|
|
1307
|
-
```
|
|
287
|
+
Same code path, bodies and results as the HTTP routes (AGENTS_FRONTEND.md). Validation, hooks, computed fields and tenant scope all run.
|
|
1308
288
|
|
|
1309
|
-
|
|
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
|
|
1310
296
|
|
|
1311
|
-
|
|
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?)
|
|
1312
304
|
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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' });
|
|
308
|
+
```
|
|
309
|
+
|
|
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.
|
|
311
|
+
|
|
312
|
+
## db (QueryClient) and ConditionBuilder
|
|
313
|
+
|
|
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.
|
|
315
|
+
|
|
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
|
|
327
|
+
```
|
|
328
|
+
|
|
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.
|
|
332
|
+
|
|
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.
|
|
343
|
+
```
|
|
344
|
+
|
|
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:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
const db = createQueryClient(pgQueryable(fastify.pg), 'postgres');
|
|
349
|
+
fastify.post('/requests/:id/claim', async (req, reply) => {
|
|
350
|
+
const col = (f: string) => SchemaRequest.col(f);
|
|
351
|
+
const guard = new ConditionBuilder('AND', db.cbDialect)
|
|
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,
|
|
355
|
+
{ [col('inProgressSince')]: new Date(), [col('inProgressBy')]: req.user.id },
|
|
356
|
+
{ [col('id')]: req.params.id }, guard);
|
|
357
|
+
return affected === 0 ? reply.code(409).send({ message: 'Already claimed or not found' }) : { claimed: true };
|
|
358
|
+
});
|
|
359
|
+
// Keep the predicate server-chosen: a client-chosen compare column makes `affected` an oracle.
|
|
1317
360
|
```
|
|
1318
361
|
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
```typescript
|
|
1322
|
-
// Public: search + get only
|
|
1323
|
-
await app.register(async (instance) => {
|
|
1324
|
-
const opts = { DbTables: publicTables };
|
|
1325
|
-
await instance.register(searchRoutes, opts);
|
|
1326
|
-
await instance.register(getRoutes, opts);
|
|
1327
|
-
}, { prefix: '/public' });
|
|
362
|
+
## Views
|
|
1328
363
|
|
|
1329
|
-
|
|
1330
|
-
await app.register(async (instance) => {
|
|
1331
|
-
const opts = { DbTables: allTables, onRequests: [adminAuth] };
|
|
1332
|
-
await instance.register(searchRoutes, opts);
|
|
1333
|
-
await instance.register(getRoutes, opts);
|
|
1334
|
-
await instance.register(insertRoutes, opts);
|
|
1335
|
-
await instance.register(updateRoutes, opts);
|
|
1336
|
-
await instance.register(deleteRoutes, opts);
|
|
1337
|
-
await instance.register(bulkUpsertRoutes, opts);
|
|
1338
|
-
await instance.register(bulkDeleteRoutes, opts);
|
|
1339
|
-
}, { prefix: '/admin' });
|
|
1340
|
-
```
|
|
364
|
+
A view (materialized ones included) is an ordinary table config (ADR 0016).
|
|
1341
365
|
|
|
1342
|
-
|
|
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.
|
|
1343
372
|
|
|
1344
|
-
|
|
1345
|
-
`readExclude`, caps still apply to whatever the model invents). Read-only surface + the
|
|
1346
|
-
manifest endpoint, agent-specific auth:
|
|
373
|
+
## Agent surface
|
|
1347
374
|
|
|
1348
|
-
|
|
1349
|
-
import { searchRoutes, getRoutes, agentManifestRoutes } from 'fastify-auto-sqlapi';
|
|
375
|
+
A read-only surface for LLM agents, with the plugin as the enforcement layer:
|
|
1350
376
|
|
|
377
|
+
```ts
|
|
1351
378
|
await app.register(async (instance) => {
|
|
1352
379
|
const opts = { DbTables: dbTables, onRequests: [agentAuth] };
|
|
1353
380
|
await instance.register(searchRoutes, opts);
|
|
1354
381
|
await instance.register(getRoutes, opts);
|
|
1355
|
-
await instance.register(agentManifestRoutes, opts);
|
|
382
|
+
await instance.register(agentManifestRoutes, opts); // GET /agent/manifest(.md)
|
|
1356
383
|
}, { prefix: '/agent' });
|
|
1357
384
|
```
|
|
1358
385
|
|
|
1359
|
-
System prompt
|
|
1360
|
-
|
|
1361
|
-
manifest is enabled with the `agentManifest: true` option. Validation strategy: either a
|
|
1362
|
-
loose generic tool (`table` enum from the manifest, free body) with the structured 400
|
|
1363
|
-
`fields[]` as the retry signal, or strict provider-side tools via
|
|
1364
|
-
`agentToolSchemas(dbTables, table)` — the exact JSON Schemas the routes validate with.
|
|
1365
|
-
|
|
1366
|
-
---
|
|
1367
|
-
|
|
1368
|
-
## Re-exports
|
|
1369
|
-
|
|
1370
|
-
The package re-exports commonly needed utilities so you don't need to install them separately:
|
|
1371
|
-
|
|
1372
|
-
```typescript
|
|
1373
|
-
import {
|
|
1374
|
-
Type, // from @sinclair/typebox
|
|
1375
|
-
type Static, // from @sinclair/typebox
|
|
1376
|
-
ConditionBuilder, // from node-condition-builder
|
|
1377
|
-
Expression, // from node-condition-builder
|
|
1378
|
-
|
|
1379
|
-
// Table configuration
|
|
1380
|
-
defineTable,
|
|
1381
|
-
exportTableInfo,
|
|
1382
|
-
buildRelation,
|
|
1383
|
-
buildUpsertRule,
|
|
1384
|
-
buildUpsertRules,
|
|
1385
|
-
Nullable, // type-array nullable schema (see Conventions)
|
|
1386
|
-
|
|
1387
|
-
// DB layer
|
|
1388
|
-
QueryClient, // raw SQL query helper
|
|
1389
|
-
createQueryClient, // factory with dialect string
|
|
1390
|
-
pgQueryable, // pg pool adapter
|
|
1391
|
-
mysqlQueryable, // mysql2 pool adapter
|
|
1392
|
-
|
|
1393
|
-
// Programmatic high-level API
|
|
1394
|
-
createSqlApi, // standalone SqlApi (for scripts/tests)
|
|
1395
|
-
setupSwagger, // manual Swagger registration
|
|
1396
|
-
} from 'fastify-auto-sqlapi';
|
|
1397
|
-
|
|
1398
|
-
import type {
|
|
1399
|
-
ValidationError, // [field, code] | [field, code, message]
|
|
1400
|
-
ValidatorFn,
|
|
1401
|
-
BulkValidatorFn,
|
|
1402
|
-
JoinDefinition,
|
|
1403
|
-
JoinRefFilter,
|
|
1404
|
-
JoinFetchRequest,
|
|
1405
|
-
JoinGroupRequest,
|
|
1406
|
-
SearchCondition,
|
|
1407
|
-
ConditionMethod,
|
|
1408
|
-
} from 'fastify-auto-sqlapi';
|
|
1409
|
-
```
|
|
1410
|
-
|
|
1411
|
-
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.
|
|
1412
|
-
|
|
1413
|
-
---
|
|
1414
|
-
|
|
1415
|
-
## 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)`.
|
|
1416
388
|
|
|
1417
|
-
|
|
389
|
+
## Exports
|
|
1418
390
|
|
|
1419
|
-
|
|
391
|
+
Everything is imported from `fastify-auto-sqlapi`, so there is nothing else to install:
|
|
1420
392
|
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
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.
|
|
1428
|
-
|
|
1429
|
-
### Prefix behavior
|
|
1430
|
-
|
|
1431
|
-
The `prefix` is a standard Fastify register option. Pass it alongside the plugin options:
|
|
1432
|
-
|
|
1433
|
-
```typescript
|
|
1434
|
-
await app.register(fastifyAutoSqlApi, { DbTables: dbTables, prefix: '/api' });
|
|
1435
|
-
```
|
|
1436
|
-
|
|
1437
|
-
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.).
|
|
1438
|
-
|
|
1439
|
-
### Dialect differences
|
|
1440
|
-
|
|
1441
|
-
| | PostgreSQL | MySQL | MariaDB |
|
|
1442
|
-
|---|---|---|---|
|
|
1443
|
-
| **Identifier quoting** | `"id"` | `` `id` `` | `` `id` `` |
|
|
1444
|
-
| **Placeholders** | `$1, $2` | `?, ?` | `?, ?` |
|
|
1445
|
-
| **RETURNING** | Yes | No | Yes (10.5+) |
|
|
1446
|
-
| **Upsert syntax** | `ON CONFLICT ... DO UPDATE` | `ON DUPLICATE KEY UPDATE` | `ON DUPLICATE KEY UPDATE` |
|
|
1447
|
-
| **Auto-increment PK** | Via `RETURNING` | Via `insertId` | Via `RETURNING` |
|
|
1448
|
-
| **CLI env vars** | `POSTGRES_*` | `MYSQL_*` | `MYSQL_*` |
|
|
1449
|
-
| **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`
|
|
1450
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`.
|