fastify-auto-sqlapi 0.2.0 → 0.2.2
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 +50 -0
- package/AGENTS_BACKEND.md +312 -9
- package/AGENTS_FRONTEND.md +1 -1
- package/BREAKING_CHANGES.md +132 -0
- package/CHANGELOG.md +170 -0
- package/README.md +255 -877
- package/dist/bin/generate-schema.js +49 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/agent/manifest.d.ts +2 -0
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +4 -0
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +15 -5
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +53 -14
- package/dist/lib/cli/schema-codegen.d.ts +2 -1
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +17 -4
- package/dist/lib/cli/tables-codegen.d.ts +4 -0
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +125 -27
- package/dist/lib/db.d.ts +6 -1
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +8 -2
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +6 -5
- package/dist/lib/engine/read-hooks.d.ts +19 -0
- package/dist/lib/engine/read-hooks.d.ts.map +1 -0
- package/dist/lib/engine/read-hooks.js +20 -0
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.js +10 -6
- package/dist/lib/engine/rest/insert.js +1 -1
- package/dist/lib/engine/rest/update.d.ts.map +1 -1
- package/dist/lib/engine/rest/update.js +15 -11
- package/dist/lib/engine/search/join-families.d.ts +11 -2
- package/dist/lib/engine/search/join-families.d.ts.map +1 -1
- package/dist/lib/engine/search/join-families.js +11 -13
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +14 -7
- package/dist/lib/engine/validate.d.ts +3 -3
- package/dist/lib/engine/validate.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.d.ts +7 -3
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +29 -30
- package/dist/lib/errors.d.ts +24 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +59 -0
- package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/schema/bulk-upsert.js +2 -1
- package/dist/lib/schema/get.d.ts +14 -0
- package/dist/lib/schema/get.d.ts.map +1 -0
- package/dist/lib/schema/get.js +18 -0
- package/dist/lib/schema/helpers.d.ts +21 -3
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +37 -5
- package/dist/lib/schema/insert.d.ts.map +1 -1
- package/dist/lib/schema/insert.js +7 -3
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +3 -11
- package/dist/lib/schema/update.d.ts.map +1 -1
- package/dist/lib/schema/update.js +7 -3
- package/dist/lib/sql-api.d.ts +9 -9
- package/dist/lib/sql-api.d.ts.map +1 -1
- package/dist/lib/sql-api.js +2 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +169 -0
- package/dist/lib/tenant.d.ts +43 -8
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +169 -54
- package/dist/lib/write-access.d.ts +36 -0
- package/dist/lib/write-access.d.ts.map +1 -0
- package/dist/lib/write-access.js +58 -0
- package/dist/routes/auto/get.routes.d.ts.map +1 -1
- package/dist/routes/auto/get.routes.js +3 -2
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +8 -2
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +13 -4
- package/dist/routes/setup-swagger.d.ts.map +1 -0
- package/dist/{lib → routes}/sql-api-decorator.d.ts +1 -1
- package/dist/routes/sql-api-decorator.d.ts.map +1 -0
- package/dist/{lib → routes}/sql-api-decorator.js +4 -4
- package/dist/types/cli.d.ts +27 -1
- package/dist/types/cli.d.ts.map +1 -1
- package/dist/types/dml.d.ts +6 -4
- package/dist/types/dml.d.ts.map +1 -1
- package/dist/types/plugin.d.ts +22 -3
- package/dist/types/plugin.d.ts.map +1 -1
- package/dist/types/request.d.ts +24 -0
- package/dist/types/request.d.ts.map +1 -0
- package/dist/types/request.js +1 -0
- package/dist/types/schema.d.ts +15 -0
- package/dist/types/schema.d.ts.map +1 -1
- package/dist/types/search.d.ts +3 -0
- package/dist/types/search.d.ts.map +1 -1
- package/dist/types/table.d.ts +84 -11
- package/dist/types/table.d.ts.map +1 -1
- package/dist/types/tenant.d.ts +26 -2
- package/dist/types/tenant.d.ts.map +1 -1
- package/dist/types/validation.d.ts +3 -3
- package/dist/types/validation.d.ts.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/docs/GLOSSARY.md +3 -1
- package/docs/adr/0004-updates-always-open.md +2 -1
- package/docs/adr/0006-raw-db-errors.md +2 -1
- package/docs/adr/0010-joins-do-not-run-route-guards.md +4 -1
- package/docs/adr/0013-sanitized-db-errors.md +114 -0
- package/docs/adr/0014-what-follows-a-write-join.md +84 -0
- package/docs/adr/0015-non-writable-columns.md +86 -0
- package/docs/adr/0016-a-view-is-a-table-config.md +92 -0
- package/docs/adr/0017-the-schema-is-the-contract.md +85 -0
- package/docs/adr/0018-one-seam-to-the-http-framework.md +68 -0
- package/docs/adr/README.md +6 -0
- package/package.json +1 -1
- package/dist/lib/setup-swagger.d.ts.map +0 -1
- package/dist/lib/sql-api-decorator.d.ts.map +0 -1
- /package/dist/{lib → routes}/setup-swagger.d.ts +0 -0
- /package/dist/{lib → routes}/setup-swagger.js +0 -0
package/README.md
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
# fastify-auto-sqlapi
|
|
2
2
|
|
|
3
|
-
**Your database already knows its tables, columns and relations. This plugin turns that knowledge into a
|
|
3
|
+
**Your database already knows its tables, columns and relations. This plugin turns that knowledge into a typed, validated REST API inside your [Fastify](https://fastify.dev/) app.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
```typescript
|
|
6
|
+
await app.register(fastifyAutoSqlApi, { DbTables: dbTables, swagger: true, prefix: '/api' });
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
### One `register()` call. Seven endpoints for every table you choose to expose.
|
|
10
|
+
|
|
11
|
+
No ORM. No GraphQL. No hand-written CRUD layer. Two CLI commands read PostgreSQL / MySQL /
|
|
12
|
+
MariaDB and generate typed schemas; the database stays the source of truth, and what you write
|
|
13
|
+
is what a database cannot know — auth, permissions, business rules — as plain TypeScript next to
|
|
14
|
+
your other routes.
|
|
6
15
|
|
|
7
|
-
|
|
16
|
+
It adds to an application that already exists rather than reorganising one: no schema file that
|
|
17
|
+
has to own your database, no migrations, no repository layout to adopt. **One table is a valid
|
|
18
|
+
installation.**
|
|
19
|
+
|
|
20
|
+
Related tables need no endpoints of their own: reads reach them through joins, writes through
|
|
21
|
+
nested children, so a whole subgraph can live behind a single exposed table.
|
|
22
|
+
|
|
23
|
+
## See what that buys you
|
|
24
|
+
|
|
25
|
+
*"Active customers with over 500 total in completed orders, with their order stats, biggest
|
|
26
|
+
spenders first."* No resolver, no query builder, no one-off endpoint:
|
|
8
27
|
|
|
9
28
|
```jsonc
|
|
10
29
|
// POST /api/search/customer?orderBy=orders.sum.total DESC&page=1&itemsPerPage=20
|
|
@@ -20,8 +39,6 @@ One request, to show the point — *"active customers with over 500 total in com
|
|
|
20
39
|
}
|
|
21
40
|
```
|
|
22
41
|
|
|
23
|
-
Under the hood this is a fixed, request-shaped number of set-based queries — the main select plus one per requested join alias (EXISTS clauses and correlated subqueries ride inside them), **never one per returned row**. There are no lazy relations to accidentally loop over: the N+1 problem is structurally absent, not something you remember to avoid.
|
|
24
|
-
|
|
25
42
|
Writes read the same way — *"sync these clients and their tags: update the ones that exist, insert the missing ones, don't duplicate a tag"*:
|
|
26
43
|
|
|
27
44
|
```jsonc
|
|
@@ -37,7 +54,7 @@ Writes read the same way — *"sync these clients and their tags: update the one
|
|
|
37
54
|
|
|
38
55
|
Conflict keys are declared server-side (`upsertMap`: here customer on `email`, tag on `(customerId, tag)`), so re-sending the same payload updates rows instead of duplicating them — client-side sync without hand-written diffing.
|
|
39
56
|
|
|
40
|
-
And multi-tenancy is one plugin option (`getTenantId: (req) => req.user
|
|
57
|
+
And multi-tenancy is one plugin option (`getTenantId: (req) => req.user?.organizationId ?? null`), enforced on reads *and* writes with zero code in handlers. Rows of other tenants are simply invisible (`404`); here a caller in org B tries to re-point one of their own orders to a customer belonging to org A:
|
|
41
58
|
|
|
42
59
|
```jsonc
|
|
43
60
|
// PUT /api/rest/order — order 7 is mine, customer 41 belongs to another tenant
|
|
@@ -47,109 +64,102 @@ And multi-tenancy is one plugin option (`getTenantId: (req) => req.user.organiza
|
|
|
47
64
|
|
|
48
65
|
No endpoint written by hand, no resolvers, no query language on the server — and these requests are schema-validated, Swagger-documented, size-capped and tenant-isolated like every other one.
|
|
49
66
|
|
|
50
|
-
##
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
67
|
+
## What you get
|
|
68
|
+
|
|
69
|
+
**Reads** — filters and equality shortcuts · advanced conditions (18 operators) · pagination
|
|
70
|
+
with caps · four explicit relation families (`joinMustExist` for EXISTS filtering,
|
|
71
|
+
`joinMultiple` for child rows, `joinGroup` for aggregations, `joinLeft` for N:1 parents) ·
|
|
72
|
+
aggregations with HAVING-style conditions · ordering by related and computed values · computed
|
|
73
|
+
fields declared as SQL expressions (JSON extraction, derived strings, date bucketing)
|
|
74
|
+
|
|
75
|
+
**Writes** — validated inserts and updates · nested child records with foreign keys filled in
|
|
76
|
+
from the parent · child deletions in the same request · bulk upsert and bulk delete ·
|
|
77
|
+
transactions, so a write and its children commit or roll back together
|
|
78
|
+
|
|
79
|
+
**Production** — multi-tenant row isolation from one `getTenantId`, enforced on reads *and*
|
|
80
|
+
writes · hooks before and after every operation, plus `afterRead` · field-level and
|
|
81
|
+
cross-entity validation returning structured `400`s · per-table route whitelists · read and
|
|
82
|
+
write column visibility · Swagger from the same schemas · request-size and pagination caps ·
|
|
83
|
+
plain parameterized SQL you can print with `debug: true`
|
|
84
|
+
|
|
85
|
+
Views are table configs like any other, materialized views included. And when generated CRUD
|
|
86
|
+
stops being the right abstraction, call `app.sqlApi.*` from a hand-written route: you keep the
|
|
87
|
+
filters, joins, tenant scoping, hooks and validation, and drop only the assumption that the
|
|
88
|
+
endpoint looks like a table.
|
|
89
|
+
|
|
90
|
+
### N+1 is structurally absent
|
|
91
|
+
|
|
92
|
+
A request runs the main select plus one query per requested join alias — a number fixed by the
|
|
93
|
+
request, **never by the number of rows returned**. There are no lazy relations to accidentally
|
|
94
|
+
loop over, so N+1 is not something you have to remember to avoid.
|
|
95
|
+
|
|
96
|
+
## Is this for you?
|
|
97
|
+
|
|
98
|
+
**Yes, if you are building an internal tool** — a back-office, an admin panel, a management
|
|
99
|
+
system where superadmin, admin and tenant-admin roles need full control over the domain. There
|
|
100
|
+
the database schema *is* the domain model, and whoever changes a column is whoever changes the
|
|
101
|
+
screen that shows it. A hand-written CRUD layer in the middle has no reader: it exists only to
|
|
102
|
+
be kept in sync with the thing it translates.
|
|
103
|
+
|
|
104
|
+
Concretely, it fits when:
|
|
105
|
+
|
|
106
|
+
- most of your endpoints are "list with filters", "get one", "save", "save many"
|
|
107
|
+
- the people who change the schema also ship the client
|
|
108
|
+
- you want tenant isolation, validation, pagination caps and Swagger without writing them per table
|
|
109
|
+
- the interesting logic is *rules* (who may do what, what must be valid) rather than *shapes*
|
|
110
|
+
- the back-office already exists and you want to stop hand-writing its next twenty endpoints,
|
|
111
|
+
without restructuring the twenty you already have
|
|
112
|
+
|
|
113
|
+
**No, if you are publishing an API you do not control the consumers of.** A public or
|
|
114
|
+
third-party API — a contract with a version number and a deprecation policy, clients you cannot
|
|
115
|
+
redeploy alongside your schema — needs a surface whose stability outlives your refactors. That
|
|
116
|
+
indirection is worth paying for there, and generating it from the schema is the wrong default.
|
|
117
|
+
Also a poor fit if your endpoints are mostly workflows rather than records, or if your data
|
|
118
|
+
model is deliberately nothing like your API model.
|
|
119
|
+
|
|
120
|
+
### It drops into a codebase that already exists
|
|
121
|
+
|
|
122
|
+
You do not have to adopt anything to try it. There is no schema file that must describe your
|
|
123
|
+
whole database first, no migration tool to take over, no repository layout to conform to —
|
|
124
|
+
which is what makes this different from adding an ORM to a project that already runs.
|
|
125
|
+
|
|
126
|
+
- **One table is a valid installation.** `sqlapi-generate-schema --tables invoice` and
|
|
127
|
+
`sqlapi-generate-tables invoice` generate for that table alone; `DbTables` contains only what
|
|
128
|
+
you put in it, and the rest of your database stays invisible to the plugin.
|
|
129
|
+
- **It never writes to your database.** Not a row, not a column: generation is `SELECT`s
|
|
130
|
+
against `information_schema`, and at runtime the plugin issues no DDL and runs no migrations.
|
|
131
|
+
Your schema is managed by whatever manages it today.
|
|
132
|
+
- **It uses the connection you already have.** The plugin reads `fastify.pg` or
|
|
133
|
+
`fastify.mysql` off the instance — if your app already registers `@fastify/postgres`, there is
|
|
134
|
+
nothing to wire.
|
|
135
|
+
- **It stays inside its prefix.** `prefix: '/api/auto'` and your existing routes never meet it.
|
|
136
|
+
You can also register only the route plugins you want (`searchRoutes`, `getRoutes`, …), or
|
|
137
|
+
mount the same tables twice under different prefixes with different auth.
|
|
138
|
+
- **Generated files go where you say** (`outputDir`), and the `Table*.ts` files are yours: the
|
|
139
|
+
generator never overwrites one that exists, so re-running it only adds files for new tables.
|
|
140
|
+
|
|
141
|
+
So a realistic first step on an existing back-office is: generate two tables, register the
|
|
142
|
+
plugin under a new prefix behind your existing auth hook, move one list screen onto it, and
|
|
143
|
+
leave everything else exactly where it is. `operations` keeps any table off the auto routes, and
|
|
144
|
+
a table with no routes at all can still be reached through a join or written as a nested child.
|
|
145
|
+
Expose the CRUD that is CRUD, hand-write the rest.
|
|
146
|
+
|
|
147
|
+
## Get a REST API in 60 seconds
|
|
80
148
|
|
|
81
149
|
```bash
|
|
82
150
|
npm install fastify-auto-sqlapi fastify @fastify/postgres
|
|
83
|
-
# MySQL/MariaDB: npm install fastify-auto-sqlapi fastify mysql2
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
### 2. Create the config file
|
|
87
|
-
|
|
88
|
-
Create `sqlapi.config.ts` (or `.mjs`/`.js`) in your project root. This is used only by the
|
|
89
|
-
CLI generators, not at runtime.
|
|
90
|
-
|
|
91
|
-
```typescript
|
|
92
|
-
export default {
|
|
93
|
-
outputDir: './src/db', // base dir for generated files (default: './src/db')
|
|
94
|
-
schema: 'public', // PostgreSQL schema to introspect (default: 'public')
|
|
95
|
-
// dialect: 'mysql', // 'postgres' (default) | 'mysql' | 'mariadb'
|
|
96
|
-
// envFile: '.env.local', // env file to load (default: '.env')
|
|
97
|
-
// excludeTables: ['knex_*'], // tables to skip in schema generation ('*' = wildcard)
|
|
98
|
-
};
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
The DB connection is read from environment variables (a `.env` file in the project root is
|
|
102
|
-
loaded automatically, without overriding variables already set):
|
|
103
|
-
|
|
104
|
-
```bash
|
|
105
|
-
# Either a full connection string:
|
|
106
|
-
DATABASE_URL=postgres://user:pass@localhost:5432/mydb # or mysql://...
|
|
107
|
-
|
|
108
|
-
# Or individual vars (PostgreSQL):
|
|
109
|
-
POSTGRES_HOST=127.0.0.1
|
|
110
|
-
POSTGRES_PORT=5432
|
|
111
|
-
POSTGRES_USER=myuser
|
|
112
|
-
POSTGRES_PASSWORD=mypassword
|
|
113
|
-
POSTGRES_DB=mydb
|
|
114
|
-
|
|
115
|
-
# Or individual vars (MySQL/MariaDB):
|
|
116
|
-
MYSQL_HOST=127.0.0.1
|
|
117
|
-
MYSQL_PORT=3306
|
|
118
|
-
MYSQL_USER=myuser
|
|
119
|
-
MYSQL_PASSWORD=mypassword
|
|
120
|
-
MYSQL_DB=mydb
|
|
151
|
+
# MySQL / MariaDB: npm install fastify-auto-sqlapi fastify mysql2
|
|
121
152
|
```
|
|
122
153
|
|
|
123
|
-
|
|
154
|
+
Create `sqlapi.config.ts` in the project root (used by the CLI only, not at runtime), then:
|
|
124
155
|
|
|
125
156
|
```bash
|
|
126
|
-
npx sqlapi-generate-schema
|
|
127
|
-
npx sqlapi-generate-
|
|
128
|
-
npx sqlapi-generate-schema --dialect mysql # override the config dialect
|
|
157
|
+
npx sqlapi-generate-schema # one TypeBox schema per table → <outputDir>/schemas/
|
|
158
|
+
npx sqlapi-generate-tables --all # one defineTable() template per table → <outputDir>/tables/
|
|
129
159
|
```
|
|
130
160
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
auto-generated — don't edit them; re-run the command after a DB change.
|
|
134
|
-
|
|
135
|
-
To keep some tables out of schema generation (migration bookkeeping, PostGIS internals, …)
|
|
136
|
-
list them in `excludeTables` in the config — exact table names or `*` globs (e.g.
|
|
137
|
-
`knex_*`). Excluded tables are invisible to the generator: their schemas are not created,
|
|
138
|
-
and any previously generated schema file is removed as an orphan on the next full run,
|
|
139
|
-
like that of a dropped table.
|
|
140
|
-
|
|
141
|
-
### 4. Generate the tables template
|
|
142
|
-
|
|
143
|
-
```bash
|
|
144
|
-
npx sqlapi-generate-tables --all # or: npx sqlapi-generate-tables customer order
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
This creates in `<outputDir>/tables/` one `Table*.ts` file per table — a `defineTable()`
|
|
148
|
-
call with auto-detected primary keys, foreign key relations, and all available options as
|
|
149
|
-
commented code — plus a `dbTables.ts` index. **These files are yours to customize**: the
|
|
150
|
-
generator never overwrites an existing file, so re-running it only adds files for new tables.
|
|
151
|
-
|
|
152
|
-
### 5. Register the plugin
|
|
161
|
+
Schema files are regenerated on every run and should not be edited. **Table files are yours**:
|
|
162
|
+
the generator never overwrites one that exists, so re-running only adds files for new tables.
|
|
153
163
|
|
|
154
164
|
```typescript
|
|
155
165
|
import Fastify from 'fastify';
|
|
@@ -158,792 +168,160 @@ import { fastifyAutoSqlApi } from 'fastify-auto-sqlapi';
|
|
|
158
168
|
import { dbTables } from './src/db/tables/dbTables.js';
|
|
159
169
|
|
|
160
170
|
const app = Fastify();
|
|
161
|
-
|
|
162
|
-
await app.register(
|
|
163
|
-
connectionString: 'postgres://user:pass@localhost:5432/mydb',
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
await app.register(fastifyAutoSqlApi, {
|
|
167
|
-
DbTables: dbTables,
|
|
168
|
-
swagger: true,
|
|
169
|
-
prefix: '/api',
|
|
170
|
-
});
|
|
171
|
-
|
|
171
|
+
await app.register(fastifyPostgres, { connectionString: 'postgres://user:pass@localhost:5432/mydb' });
|
|
172
|
+
await app.register(fastifyAutoSqlApi, { DbTables: dbTables, swagger: true, prefix: '/api' });
|
|
172
173
|
await app.listen({ port: 3000 });
|
|
173
174
|
```
|
|
174
175
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
| Method | URL |
|
|
178
|
-
|
|
179
|
-
| `POST` | `/api/search/customer` |
|
|
180
|
-
| `GET` | `/api/rest/customer/:id` |
|
|
181
|
-
| `POST` | `/api/rest/customer` |
|
|
182
|
-
| `PUT` | `/api/rest/customer` |
|
|
183
|
-
| `DELETE` | `/api/rest/customer/:id` |
|
|
184
|
-
| `PUT` | `/api/bulk/customer` |
|
|
185
|
-
| `POST` | `/api/bulk/customer/delete` |
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
176
|
+
For a table called `customer` you now have:
|
|
177
|
+
|
|
178
|
+
| Method | URL | |
|
|
179
|
+
|--------|-----|---|
|
|
180
|
+
| `POST` | `/api/search/customer` | search with filters, pagination, joins, aggregations |
|
|
181
|
+
| `GET` | `/api/rest/customer/:id` | get one by primary key |
|
|
182
|
+
| `POST` | `/api/rest/customer` | insert (+ nested children) |
|
|
183
|
+
| `PUT` | `/api/rest/customer` | update (+ nested children and deletions) |
|
|
184
|
+
| `DELETE` | `/api/rest/customer/:id` | delete by primary key |
|
|
185
|
+
| `PUT` | `/api/bulk/customer` | bulk upsert |
|
|
186
|
+
| `POST` | `/api/bulk/customer/delete` | bulk delete |
|
|
187
|
+
|
|
188
|
+
Search is a `POST` because the filters travel as JSON in the body. Every option of `defineTable()` and of `register()` is documented in
|
|
189
|
+
[AGENTS_BACKEND.md](./AGENTS_BACKEND.md); the full request grammar is in
|
|
190
|
+
[AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md).
|
|
191
|
+
|
|
192
|
+
## Why this approach
|
|
193
|
+
|
|
194
|
+
The first objection is always that this couples the API to the database. It does, deliberately,
|
|
195
|
+
and the answer is worth stating precisely.
|
|
196
|
+
|
|
197
|
+
**A hand-written CRUD layer over the same tables is coupled to the same schema** — it just
|
|
198
|
+
retypes it. Rename a column there and you edit the model, the DTO, the mapper, the validator,
|
|
199
|
+
the Swagger annotation and the client: six places instead of one, and the coupling is still
|
|
200
|
+
there. What that layer really buys is an indirection that lets the contract stay still while the
|
|
201
|
+
schema moves — worth wanting, paid for continuously, cashed in rarely. This plugin makes the
|
|
202
|
+
opposite bet, and the bet is the point.
|
|
203
|
+
|
|
204
|
+
**The coupling is checked, not implied.** Schemas are generated from `information_schema`, so a
|
|
205
|
+
schema change that breaks the API breaks it when you regenerate and compile. A hand-written
|
|
206
|
+
mapper nobody remembered to update fails in production instead.
|
|
207
|
+
|
|
208
|
+
**And it is a default, not a fact.** Your schema defines the shape; your code defines the rules.
|
|
209
|
+
The shape you expose is not forced to be the shape you store — there is a specific lever for
|
|
210
|
+
each way they need to differ:
|
|
211
|
+
|
|
212
|
+
| To decouple | Use |
|
|
213
|
+
|---|---|
|
|
214
|
+
| a column must never be read | `readExclude` |
|
|
215
|
+
| a column must never be written | `writeExclude` |
|
|
216
|
+
| a column must not exist for the API at all | trim it out of the Schema |
|
|
217
|
+
| the API type must be stricter than the column | `schemaOverrides` |
|
|
218
|
+
| the value is derived, not stored | `computedFields` |
|
|
219
|
+
| the stored representation is not the API one | `afterRead` (decrypt, unpack, rescale) |
|
|
220
|
+
| a table must not have all seven routes | `operations` |
|
|
221
|
+
| a relation must be named for the API, not the DB | the relation `alias` and its `fields` allowlist |
|
|
222
|
+
| a filter has no column behind it | `extraFilters` |
|
|
223
|
+
|
|
224
|
+
When none of them fits, the operation was never CRUD: write the route by hand and call
|
|
225
|
+
`app.sqlApi.*` inside it — you keep filters, joins, tenant scoping, hooks and validation, and
|
|
226
|
+
drop only the assumption that the endpoint looks like a table. Full rationale in
|
|
227
|
+
[ADR 0017](./docs/adr/0017-the-schema-is-the-contract.md).
|
|
228
|
+
|
|
229
|
+
### Not an ORM, not GraphQL — a third thing
|
|
230
|
+
|
|
231
|
+
- **Not an ORM.** An ORM is a library *your* code uses to talk to the database — you still
|
|
232
|
+
hand-write every endpoint on top of it. This generates the endpoints themselves, with no ORM
|
|
233
|
+
underneath: no models, no migrations, no second schema to keep in sync. And no schema file
|
|
234
|
+
that has to describe your whole database before anything works: a Prisma-style `schema.prisma`
|
|
235
|
+
becomes the source of truth and the thing you migrate through, so adopting it is a decision
|
|
236
|
+
about the entire database. Here the database stays the source of truth and generation is
|
|
237
|
+
read-only — the CLI issues `SELECT`s against `information_schema` and nothing else, ever.
|
|
238
|
+
Every request runs as plain parameterized SQL you can read with `debug: true`.
|
|
239
|
+
- **Not GraphQL.** GraphQL buys client-driven flexibility with a schema layer, hand-written
|
|
240
|
+
resolvers and their N+1 traps, mutations one by one, and query-cost analysis to stop hostile
|
|
241
|
+
requests. This covers what most projects reach for GraphQL for — filter, paginate, join,
|
|
242
|
+
aggregate in one round trip — with a fixed JSON grammar over plain REST: curl-able,
|
|
243
|
+
Swagger-documented, bounded by design. The write side is generated too. What you give up:
|
|
244
|
+
arbitrarily deep nesting, per-field selection on the main table, subscriptions.
|
|
245
|
+
- **Not a hosted black box.** PostgREST / Hasura / Supabase give you an API as a separate
|
|
246
|
+
service, configured from outside. This is a plugin inside your own app: hooks, validation and
|
|
247
|
+
auth are TypeScript functions in your codebase, and you can always drop to `app.sqlApi.*` or
|
|
248
|
+
raw SQL in a custom route. No lock-in.
|
|
249
|
+
|
|
250
|
+
Queries are set-based: the main select plus one per requested join alias, **never one per
|
|
251
|
+
returned row**. There are no lazy relations to loop over — N+1 is structurally absent, not
|
|
252
|
+
something you remember to avoid.
|
|
253
|
+
|
|
254
|
+
## Before you ship
|
|
255
|
+
|
|
256
|
+
> ⚠️ **The plugin is open by default.** Registering it with no further configuration exposes
|
|
257
|
+
> **every operation on every table in `DbTables`** — reads *and* writes, bulk delete included —
|
|
258
|
+
> to anyone who can reach the server. That is intentional: the plugin provides the tools and
|
|
259
|
+
> imposes no auth model ([ADR 0002](./docs/adr/0002-open-by-default.md)). Locking it down is
|
|
260
|
+
> yours to do.
|
|
261
|
+
|
|
262
|
+
Five things decide whether a deployment is safe. Each is one option, and each is covered in
|
|
263
|
+
full in [AGENTS_BACKEND.md](./AGENTS_BACKEND.md).
|
|
264
|
+
|
|
265
|
+
**1. Who may call the routes** — `onRequests` runs before every generated route, globally or per
|
|
266
|
+
table. It is where `jwtVerify()` and your role checks go.
|
|
267
|
+
|
|
268
|
+
**2. Which routes exist** — `operations: ['search', 'get']` registers only those; the rest answer
|
|
269
|
+
404. It gates HTTP only: your own code still reaches everything through `app.sqlApi.*`.
|
|
270
|
+
|
|
271
|
+
**3. A declared join is a read grant.** Neither of the two above follows a join: a join is
|
|
272
|
+
resolved inside the *host* table's query, so the target's hooks never run and its `operations`
|
|
273
|
+
whitelist does not apply. A table with `operations: []` is still fully readable through any
|
|
274
|
+
relation pointing at it. `allowedReadJoins` is therefore a security decision — and
|
|
275
|
+
`buildRelation(..., { fields: ['id', 'name'] })` narrows what a relation exposes, fail-closed.
|
|
276
|
+
([ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md),
|
|
277
|
+
[ADR 0011](./docs/adr/0011-join-fields-allowlist.md))
|
|
278
|
+
|
|
279
|
+
**4. Every Schema field is updatable by default.** `excludeFromCreation` is an ergonomics tool
|
|
280
|
+
for inserts, not a security mechanism, and it deliberately does not apply to updates. So
|
|
281
|
+
`isAdmin`, roles, ownership and state columns *will* be accepted by the generated update route
|
|
282
|
+
if they are in the Schema. Whether a field may change is a product decision the plugin cannot
|
|
283
|
+
make for you — encode it in `beforeUpdate` (silent strip) or `validate` (loud 400), or keep the
|
|
284
|
+
privileged transition off the auto routes entirely.
|
|
285
|
+
([ADR 0004](./docs/adr/0004-updates-always-open.md))
|
|
286
|
+
|
|
287
|
+
**5. What leaves the server.** Any error the plugin did not raise itself becomes a bare `500`
|
|
288
|
+
carrying a `requestId` — driver messages name your tables, columns and constraints, and they
|
|
289
|
+
stay in the log. No status code is remapped (a unique violation is a `500`, not a `409`: that
|
|
290
|
+
mapping is product logic, for your `setErrorHandler`). Set `exposeDebugInfo: true` while
|
|
291
|
+
developing to get the driver detail on the wire, additively.
|
|
292
|
+
([ADR 0013](./docs/adr/0013-sanitized-db-errors.md))
|
|
293
|
+
|
|
294
|
+
Two caps are on by default: `maxItemsPerPage` (1000) bounds a search — and is applied as the
|
|
295
|
+
`LIMIT` even with no paginator, so an empty-body search cannot dump a table — and `maxBulkItems`
|
|
296
|
+
(1000) bounds a bulk array. Write bodies are `additionalProperties: false`, so the Schema is the
|
|
297
|
+
write whitelist and there is no mass assignment; note that Fastify *strips* an unknown field by
|
|
298
|
+
default rather than rejecting it.
|
|
299
|
+
|
|
300
|
+
## Documentation
|
|
301
|
+
|
|
302
|
+
| | |
|
|
303
|
+
|---|---|
|
|
304
|
+
| [AGENTS_BACKEND.md](./AGENTS_BACKEND.md) | **The reference.** Setup, plugin options, every `defineTable()` key, joins, computed fields, hooks, validation, multi-tenancy, views, `SqlApi`, `QueryClient`, Swagger, patterns, FAQ. Opens with a section map — jump, don't scan. |
|
|
305
|
+
| [AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md) | **The client contract.** Every endpoint's request and response shape, the four join families, conditions, ordering, pagination, error shapes. |
|
|
306
|
+
| [docs/adr/](./docs/adr/README.md) | **Why it behaves like this.** Open-by-default, non-transactional bulk, always-updatable fields, raw DB errors, the schema-as-contract bet. Read the relevant one before filing an issue that proposes changing one of these. |
|
|
307
|
+
| [CHANGELOG.md](./CHANGELOG.md) · [BREAKING_CHANGES.md](./BREAKING_CHANGES.md) | What changed, and how to migrate. |
|
|
308
|
+
|
|
309
|
+
The two `AGENTS_*` files are named for their other audience — they ship inside the npm package
|
|
310
|
+
so a coding agent finds them after `npm install` — but they are the reference documentation for
|
|
311
|
+
people too, and they are written to be read. Point an agent at them once:
|
|
202
312
|
|
|
203
313
|
> Read `node_modules/fastify-auto-sqlapi/AGENTS.md` before touching anything under `src/db/`.
|
|
204
314
|
|
|
205
|
-
Three
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
- **One naming convention.** camelCase in requests, responses, hooks and validators; the
|
|
211
|
-
mapping to DB columns is automatic. There is no second convention to remember, and no
|
|
212
|
-
layer where the agent has to guess which one applies.
|
|
213
|
-
- **Errors are machine-readable.** A 400 carries `fields: [{ path, code, message }]`, so a
|
|
214
|
-
failing request tells the agent exactly what to fix instead of requiring a guess.
|
|
215
|
-
|
|
216
|
-
The ADRs matter more than they look: they answer the "why is it like this?" questions
|
|
217
|
-
(open-by-default, non-transactional bulk, always-updatable fields) that an agent would
|
|
218
|
-
otherwise resolve by inventing a workaround — or by proposing to change intentional behaviour.
|
|
219
|
-
|
|
220
|
-
## Table Configuration
|
|
221
|
-
|
|
222
|
-
Tables are configured with `defineTable()`. The only required fields are `primary` and the output of `exportTableInfo()`:
|
|
223
|
-
|
|
224
|
-
```typescript
|
|
225
|
-
import { defineTable, exportTableInfo } from 'fastify-auto-sqlapi';
|
|
226
|
-
import { SchemaCustomer } from './SchemaCustomer.js';
|
|
227
|
-
|
|
228
|
-
const TableCustomer = defineTable({
|
|
229
|
-
primary: 'id',
|
|
230
|
-
...exportTableInfo(SchemaCustomer),
|
|
231
|
-
});
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
`exportTableInfo()` provides the schema, a filter builder (auto-generates WHERE clauses from request fields), and extra filter definitions.
|
|
235
|
-
|
|
236
|
-
Export all your tables as a single record:
|
|
237
|
-
|
|
238
|
-
```typescript
|
|
239
|
-
export const dbTables = {
|
|
240
|
-
customer: TableCustomer,
|
|
241
|
-
order: TableOrder,
|
|
242
|
-
};
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
The keys in this record (`customer`, `order`) become the table names in the URL paths.
|
|
246
|
-
|
|
247
|
-
### All available options
|
|
248
|
-
|
|
249
|
-
```typescript
|
|
250
|
-
const TableCustomer = defineTable({
|
|
251
|
-
// Required
|
|
252
|
-
primary: 'id',
|
|
253
|
-
...exportTableInfo(SchemaCustomer),
|
|
254
|
-
|
|
255
|
-
// Ordering & filtering
|
|
256
|
-
defaultOrder: 'name', // default ORDER BY (camelCase fields map to DB columns)
|
|
257
|
-
excludeFromCreation: ['id'], // client-sent values ignored on INSERT (auto-increment,
|
|
258
|
-
// DB defaults); beforeInsert can still set them
|
|
259
|
-
readExclude: ['passwordHash'], // hide from all reads (writes unaffected)
|
|
260
|
-
distinctResults: true, // SELECT DISTINCT
|
|
261
|
-
|
|
262
|
-
// Relations — alias defaults to joinSchema.tableName. Override with `{ alias: '...' }`
|
|
263
|
-
// when you join the same table twice (e.g. `createdBy`/`updatedBy`) or want a friendlier
|
|
264
|
-
// name. Set `unique: true` for N:1 (parent) relations to enable `joinLeft`.
|
|
265
|
-
allowedReadJoins: [
|
|
266
|
-
buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
|
|
267
|
-
buildRelation(SchemaSession, 'userId', SchemaUser, 'id', { unique: true }), // alias = 'user', N:1
|
|
268
|
-
buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true }),
|
|
269
|
-
// `fields` restricts what this relation exposes of the target — read joins only
|
|
270
|
-
buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true, fields: ['id', 'name'] }),
|
|
271
|
-
],
|
|
272
|
-
allowedWriteJoins: [
|
|
273
|
-
buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
|
|
274
|
-
],
|
|
275
|
-
|
|
276
|
-
// Upsert (ON CONFLICT)
|
|
277
|
-
upsertMap: buildUpsertRules(
|
|
278
|
-
buildUpsertRule(SchemaCustomer, ['id']),
|
|
279
|
-
),
|
|
280
|
-
|
|
281
|
-
// Schema overrides (tighten generated schema without editing Schema files)
|
|
282
|
-
schemaOverrides: {
|
|
283
|
-
email: Type.String({ format: 'email' }),
|
|
284
|
-
},
|
|
285
|
-
|
|
286
|
-
// Multi-tenant isolation
|
|
287
|
-
tenantScope: { column: 'organization_id' },
|
|
288
|
-
|
|
289
|
-
// Validation (runs after schema validation, before hooks)
|
|
290
|
-
validate: async (db, req, main, secondaries) => {
|
|
291
|
-
// Return ValidationError[] — tuple: [field, code] or [field, code, message]
|
|
292
|
-
// message defaults to code if omitted
|
|
293
|
-
if (!main.name) return [['name', 'required']];
|
|
294
|
-
return [];
|
|
295
|
-
},
|
|
296
|
-
validateBulk: async (db, req, items) => {
|
|
297
|
-
// Bulk-upsert only. Called once with all items for cross-item validation.
|
|
298
|
-
return [];
|
|
299
|
-
},
|
|
300
|
-
|
|
301
|
-
// Hooks (run after validation) — all receive camelCase records (schema field names).
|
|
302
|
-
// after* hooks for insert/update run INSIDE the write transaction: throwing rolls back.
|
|
303
|
-
beforeInsert: async (db, req, record) => { /* camelCase; mutations propagate to INSERT */ },
|
|
304
|
-
afterInsert: async (db, req, record, secondaryRecords) => { /* camelCase; input merged with generated PK */ },
|
|
305
|
-
beforeUpdate: async (db, req, fields) => { /* camelCase; PK included for reference, excluded from UPDATE SET */ },
|
|
306
|
-
afterUpdate: async (db, req, record, secondaryRecords, deletionRecords) => { /* after UPDATE + secondaries + deletions */ },
|
|
307
|
-
beforeDelete: async (db, req, id) => { /* throw to abort the deletion */ },
|
|
308
|
-
afterDelete: async (db, req, id) => { /* after a successful single delete */ },
|
|
309
|
-
beforeBulkDelete: async (db, req, ids) => { /* called ONCE with all ids; throw to abort the batch */ },
|
|
310
|
-
afterBulkDelete: async (db, req, deletedIds) => { /* called ONCE with the ids ACTUALLY deleted */ },
|
|
311
|
-
|
|
312
|
-
// Auth (per-table)
|
|
313
|
-
onRequests: [
|
|
314
|
-
async (request, reply) => {
|
|
315
|
-
if (!request.user) return reply.status(401).send({ error: 'Unauthorized' });
|
|
316
|
-
},
|
|
317
|
-
],
|
|
318
|
-
});
|
|
319
|
-
```
|
|
320
|
-
|
|
321
|
-
### Custom filters (extraFilters)
|
|
322
|
-
|
|
323
|
-
For filters that don't map to real columns (e.g. a search `q` field):
|
|
324
|
-
|
|
325
|
-
```typescript
|
|
326
|
-
import { Type, ConditionBuilder } from 'fastify-auto-sqlapi';
|
|
327
|
-
|
|
328
|
-
const TableCustomer = defineTable({
|
|
329
|
-
primary: 'id',
|
|
330
|
-
...exportTableInfo(
|
|
331
|
-
SchemaCustomer,
|
|
332
|
-
{ q: Type.Optional(Type.String()) }, // extra filter definition
|
|
333
|
-
(condition, filters) => { // custom condition builder
|
|
334
|
-
if (filters.q) {
|
|
335
|
-
const or = new ConditionBuilder('OR');
|
|
336
|
-
or.isILike('name', `%${filters.q}%`);
|
|
337
|
-
or.isILike('email', `%${filters.q}%`);
|
|
338
|
-
condition.append(or);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
),
|
|
342
|
-
});
|
|
343
|
-
```
|
|
344
|
-
|
|
345
|
-
## Plugin Options
|
|
346
|
-
|
|
347
|
-
```typescript
|
|
348
|
-
await app.register(fastifyAutoSqlApi, {
|
|
349
|
-
DbTables: dbTables, // Required — your table definitions
|
|
350
|
-
dialect: 'postgres', // Optional — 'postgres' | 'mysql' | 'mariadb' (default: 'postgres')
|
|
351
|
-
prefix: '/api', // Optional — URL prefix for all routes
|
|
352
|
-
swagger: true, // Optional — enable Swagger UI (or pass SwaggerOptions)
|
|
353
|
-
onRequests: [authMiddleware], // Optional — global hooks applied to every route
|
|
354
|
-
getTenantId: (req) => id, // Optional — multi-tenant function
|
|
355
|
-
maxItemsPerPage: 1000, // Optional — cap on search page size (default: 1000)
|
|
356
|
-
maxBulkItems: 1000, // Optional — cap on bulk array length (default: 1000)
|
|
357
|
-
debug: true, // Optional — log all SQL queries and params
|
|
358
|
-
});
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
| Option | Type | Required | Description |
|
|
362
|
-
|--------|------|----------|-------------|
|
|
363
|
-
| `DbTables` | `Record<string, ITable>` | Yes | Table configurations |
|
|
364
|
-
| `dialect` | `'postgres' \| 'mysql' \| 'mariadb'` | No | DB dialect (default: `'postgres'`) |
|
|
365
|
-
| `prefix` | `string` | No | URL prefix (e.g. `/api`) |
|
|
366
|
-
| `swagger` | `boolean \| SwaggerOptions` | No | Enable Swagger UI |
|
|
367
|
-
| `onRequests` | `Function[]` | No | Global auth/middleware hooks |
|
|
368
|
-
| `getTenantId` | `(req) => id \| null` | No | Tenant resolver for multi-tenant |
|
|
369
|
-
| `maxItemsPerPage` | `number` | No | Max search page size, and the row `LIMIT` applied when no paginator is sent (default: `1000`) |
|
|
370
|
-
| `maxBulkItems` | `number` | No | Max number of items accepted by the bulk endpoints (default: `1000`) |
|
|
371
|
-
| `debug` | `boolean` | No | Log all SQL queries to console |
|
|
372
|
-
|
|
373
|
-
The plugin picks up the connection from the Fastify instance: `fastify.pg` for PostgreSQL
|
|
374
|
-
(what `@fastify/postgres` decorates) or `fastify.mysql` for MySQL/MariaDB. For MySQL,
|
|
375
|
-
decorate a `mysql2/promise` pool (e.g. via `@fastify/mysql` with `promise: true`, or manually)
|
|
376
|
-
and pass `dialect: 'mysql'` or `'mariadb'`:
|
|
377
|
-
|
|
378
|
-
```typescript
|
|
379
|
-
import mysql from 'mysql2/promise';
|
|
380
|
-
|
|
381
|
-
const pool = mysql.createPool({ host: '127.0.0.1', user: 'myuser', password: '...', database: 'mydb' });
|
|
382
|
-
app.decorate('mysql', pool);
|
|
383
|
-
app.addHook('onClose', async () => { await pool.end(); });
|
|
384
|
-
|
|
385
|
-
await app.register(fastifyAutoSqlApi, { DbTables: dbTables, dialect: 'mysql' });
|
|
386
|
-
```
|
|
387
|
-
|
|
388
|
-
## Security
|
|
389
|
-
|
|
390
|
-
> ⚠️ **The plugin is open by default.** Registering it without any configuration exposes
|
|
391
|
-
> **all operations on all tables in `DbTables`** (read AND write, including bulk delete) to
|
|
392
|
-
> anyone who can reach the server. This is intentional — the plugin provides the tools and
|
|
393
|
-
> does not impose an auth model — but it means **you** are responsible for locking it down
|
|
394
|
-
> before exposing it.
|
|
395
|
-
|
|
396
|
-
Three layers are available, combinable:
|
|
397
|
-
|
|
398
|
-
**1. Authentication / authorization hooks** — `onRequests` runs before every auto-generated
|
|
399
|
-
route (globally or per table):
|
|
400
|
-
|
|
401
|
-
```typescript
|
|
402
|
-
await app.register(fastifyAutoSqlApi, {
|
|
403
|
-
DbTables: dbTables,
|
|
404
|
-
onRequests: [async (request, reply) => {
|
|
405
|
-
await request.jwtVerify(); // or any auth check; throw/reply to block
|
|
406
|
-
}],
|
|
407
|
-
});
|
|
408
|
-
```
|
|
409
|
-
|
|
410
|
-
**2. Per-table operation whitelist** — `operations` limits which routes are registered for a
|
|
411
|
-
table. Omitted = all operations (default). Unlisted operations are not registered at all
|
|
412
|
-
(they answer 404):
|
|
413
|
-
|
|
414
|
-
```typescript
|
|
415
|
-
const TableAuditLog = defineTable({
|
|
416
|
-
primary: 'id',
|
|
417
|
-
...exportTableInfo(SchemaAuditLog),
|
|
418
|
-
operations: ['search', 'get'], // read-only over HTTP: no insert/update/delete/bulk
|
|
419
|
-
});
|
|
420
|
-
```
|
|
421
|
-
|
|
422
|
-
Note: `operations` gates the HTTP routes only. The programmatic `fastify.sqlApi.*` methods
|
|
423
|
-
are always available to your own code.
|
|
424
|
-
|
|
425
|
-
**3. Multi-tenancy** — `getTenantId` + `tenantScope` filter every query by tenant (see
|
|
426
|
-
[Multi-Tenant](#multi-tenant)).
|
|
427
|
-
|
|
428
|
-
### A declared join is a read grant
|
|
429
|
-
|
|
430
|
-
⚠️ **Neither of the first two layers follows a join.** `onRequests` and `operations` are
|
|
431
|
-
properties of the routes generated *for* a table; a join is resolved inside the query of the
|
|
432
|
-
**host** table's route, so the target's hooks never run and its `operations` whitelist does not
|
|
433
|
-
apply. A table with `operations: []` — no HTTP routes at all — is still fully readable through
|
|
434
|
-
any relation pointing at it.
|
|
435
|
-
|
|
436
|
-
So `allowedReadJoins` is a security decision: declaring a relation grants read access to the
|
|
437
|
-
target table under the **host** table's authorization.
|
|
438
|
-
|
|
439
|
-
What does follow a join: `readExclude`, `tenantScope`, and the schema the relation was declared
|
|
440
|
-
with. To expose a table broadly while keeping some columns narrow, give the relation a `fields`
|
|
441
|
-
allowlist:
|
|
442
|
-
|
|
443
|
-
```typescript
|
|
444
|
-
allowedReadJoins: [
|
|
445
|
-
buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', {
|
|
446
|
-
unique: true,
|
|
447
|
-
fields: ['id', 'name'], // everything else on `user` is unreachable here
|
|
448
|
-
}),
|
|
449
|
-
],
|
|
450
|
-
```
|
|
451
|
-
|
|
452
|
-
Every read surface validates against it — `selection`, `filters`, `conditions`, `orderBy`,
|
|
453
|
-
aggregations, and the generated request/response schemas — so `email` is `400 Unknown field`
|
|
454
|
-
everywhere, not just missing from the projection. It is fail-closed: a column added to `user`
|
|
455
|
-
later is not reachable through this relation until it is added to the list.
|
|
456
|
-
|
|
457
|
-
`fields` restricts **reading** only, and only through that relation. It is not a replacement for
|
|
458
|
-
`readExclude` (which hides a column from the table's own routes too) or for `onRequests` (which
|
|
459
|
-
decides who may call them).
|
|
460
|
-
|
|
461
|
-
Full rationale and the rejected alternatives in
|
|
462
|
-
[ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md) and
|
|
463
|
-
[ADR 0011](./docs/adr/0011-join-fields-allowlist.md).
|
|
464
|
-
|
|
465
|
-
### Request limits
|
|
466
|
-
|
|
467
|
-
Two hard caps bound how much work a single request can trigger (defense against accidental or
|
|
468
|
-
malicious resource exhaustion — relevant precisely because the API is open by default):
|
|
469
|
-
|
|
470
|
-
- **Search result size** — `itemsPerPage` above `maxItemsPerPage` (default `1000`) is rejected
|
|
471
|
-
with `400`. When a search omits pagination entirely, the same value is applied as a `LIMIT`, so
|
|
472
|
-
an empty-body `POST /search/:table` can never dump an entire table.
|
|
473
|
-
- **Bulk array size** — the `PUT /bulk/:table` and `POST /bulk/:table/delete` bodies reject arrays
|
|
474
|
-
longer than `maxBulkItems` (default `1000`) at schema validation.
|
|
475
|
-
|
|
476
|
-
Both are configurable in the plugin options. The programmatic `fastify.sqlApi.*` methods are not
|
|
477
|
-
capped — they run your own trusted code.
|
|
478
|
-
|
|
479
|
-
### Write whitelist
|
|
480
|
-
|
|
481
|
-
Insert/update/bulk-upsert bodies are `additionalProperties: false`: only columns present in the
|
|
482
|
-
generated Schema (as narrowed by `schemaOverrides` / `excludeFromCreation`) can be written. The
|
|
483
|
-
Schema is the write whitelist. Trim the generated Schema (or use `excludeFromCreation`) to keep
|
|
484
|
-
sensitive columns out of it.
|
|
485
|
-
|
|
486
|
-
An unexpected field is **stripped, not rejected**: Fastify runs Ajv with `removeAdditional: true`,
|
|
487
|
-
so the request succeeds and the column is simply never written. Mass assignment is prevented
|
|
488
|
-
either way, but do not expect a `400` to tell a client its extra field was ignored.
|
|
489
|
-
|
|
490
|
-
### Field-level update rules are product logic — use the hooks
|
|
491
|
-
|
|
492
|
-
`excludeFromCreation` is an **ergonomics tool for creation**, not a security mechanism: it
|
|
493
|
-
exists so auto-generated values (serial PKs, DB-default timestamps) don't have to be sent —
|
|
494
|
-
or validated — on insert. It deliberately does **not** apply to updates: every field in the
|
|
495
|
-
Schema is updatable by default, because whether a field may change is a product decision the
|
|
496
|
-
plugin cannot make for you (a super admin may legitimately fix an `updatedAt`; in one product
|
|
497
|
-
users may move themselves across tenants, in another nobody may).
|
|
498
|
-
|
|
499
|
-
So watch out for **sensitive flags and ownership fields** — `isAdmin`, `role`, tenant/owner
|
|
500
|
-
columns, state fields: if they are in the Schema, the auto-generated update route will accept
|
|
501
|
-
them. Encode your rules with the tools made for it:
|
|
502
|
-
|
|
503
|
-
```typescript
|
|
504
|
-
// Silent strip: non-admins simply cannot touch the flag
|
|
505
|
-
beforeUpdate: async (db, req, fields) => {
|
|
506
|
-
if (!req.user.isAdmin) delete fields.isAdmin;
|
|
507
|
-
},
|
|
508
|
-
|
|
509
|
-
// Loud 400: touching the flag without permission is an error
|
|
510
|
-
validate: async (db, req, main) => {
|
|
511
|
-
if (main.isAdmin !== undefined && !req.user.isAdmin)
|
|
512
|
-
return [['isAdmin', 'forbidden']];
|
|
513
|
-
return [];
|
|
514
|
-
},
|
|
515
|
-
```
|
|
516
|
-
|
|
517
|
-
For privileged state transitions (promoting an admin, moving a record across tenants) consider
|
|
518
|
-
a dedicated endpoint with its own auth and audit instead of the auto-generated CRUD route —
|
|
519
|
-
`operations` lets you keep the sensitive operation off the auto routes entirely.
|
|
520
|
-
|
|
521
|
-
Note on tenants: when `tenantScope` is active, tenant-scoped callers can never move a record to
|
|
522
|
-
another tenant (the tenant column is enforced server-side); admin callers (`getTenantId` →
|
|
523
|
-
`null`) are unrestricted. That is the isolation contract of the opt-in tenancy feature, not a
|
|
524
|
-
field-level rule.
|
|
525
|
-
|
|
526
|
-
### Read visibility
|
|
527
|
-
|
|
528
|
-
`readExclude` hides columns from every read while leaving writes untouched — the case for a
|
|
529
|
-
password hash or an access token: writable, never readable.
|
|
530
|
-
|
|
531
|
-
```typescript
|
|
532
|
-
readExclude: ['accessToken'],
|
|
533
|
-
```
|
|
534
|
-
|
|
535
|
-
Excluded fields are not selected by search/get, are omitted from read response schemas and from
|
|
536
|
-
the table's default join selection, and cannot be referenced from `filters`, `conditions`,
|
|
537
|
-
`orderBy`, aggregations or an explicit join `selection` (`400`). That last part is the point:
|
|
538
|
-
letting a hidden field be filtered would leak its value by bisection. Primary keys cannot be
|
|
539
|
-
excluded.
|
|
540
|
-
|
|
541
|
-
## API Reference
|
|
542
|
-
|
|
543
|
-
### POST /search/{table}
|
|
544
|
-
|
|
545
|
-
Search with filters, advanced conditions, pagination, ordering, joins, and aggregations.
|
|
546
|
-
|
|
547
|
-
**Request body** (all fields optional):
|
|
548
|
-
|
|
549
|
-
```json
|
|
550
|
-
{
|
|
551
|
-
"filters": { "name": "Mario" },
|
|
552
|
-
"conditions": [
|
|
553
|
-
{ "field": "total", "method": "isGreater", "params": [100] },
|
|
554
|
-
{ "field": "createdAt", "method": "isBetween", "params": ["2024-01-01", "2024-12-31"] }
|
|
555
|
-
],
|
|
556
|
-
"joinMustExist": {
|
|
557
|
-
"orders": {
|
|
558
|
-
"filters": { "status": "completed" },
|
|
559
|
-
"conditions": [{ "field": "total", "method": "isGreater", "params": [50] }]
|
|
560
|
-
}
|
|
561
|
-
},
|
|
562
|
-
"joinMultiple": {
|
|
563
|
-
"orders": {
|
|
564
|
-
"filters": { "status": "completed" },
|
|
565
|
-
"selection": "id,total,status"
|
|
566
|
-
}
|
|
567
|
-
},
|
|
568
|
-
"joinGroup": {
|
|
569
|
-
"orders": {
|
|
570
|
-
"aggregations": {
|
|
571
|
-
"by": "status",
|
|
572
|
-
"sum": ["total"],
|
|
573
|
-
"min": ["total"],
|
|
574
|
-
"max": ["total"],
|
|
575
|
-
"avg": ["total"],
|
|
576
|
-
"count": ["id"],
|
|
577
|
-
"distinctCount": ["status"]
|
|
578
|
-
},
|
|
579
|
-
"filters": { "status": "completed" }
|
|
580
|
-
}
|
|
581
|
-
},
|
|
582
|
-
"joinLeft": {
|
|
583
|
-
"creator": { "selection": "id,name,email" }
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
```
|
|
587
|
-
|
|
588
|
-
- **`filters`** — equality-based, flat key/value. Supports schema fields, `extraFilters` and computed fields. A key that is none of those is a `400 Unknown filter field: <key>` — a mistyped filter must never come back as a wider result set. An explicit `null` filters by `IS NULL`; `undefined` means "not supplied" and is ignored.
|
|
589
|
-
- **`conditions`** — array of `{ field, method, params }`. Methods: `isEqual`, `isNotEqual`, `isGreater`, `isGreaterOrEqual`, `isLess`, `isLessOrEqual`, `isLike`, `isILike`, `isIn`, `isNotIn`, `isBetween`, `isNotBetween`, `isNull`, `isNotNull`.
|
|
590
|
-
- **`joinMustExist`** — EXISTS-based filter: "main rows where at least one related row matches". Accepts `{ filters, conditions }` (both optional). Aliases must come from `allowedReadJoins` declarations with `unique: false`.
|
|
591
|
-
- **`joinMultiple`** — fetches related child rows in a side query. Accepts `{ filters, conditions, selection }`. Same `unique: false` aliases.
|
|
592
|
-
- **`joinGroup`** — aggregations on the related table. Supports `sum`, `min`, `max`, `avg`, `count`, `distinctCount`, and optional `by` for GROUP BY (a schema field name or a computed-field name declared on the join table — e.g. for date bucketing declare a computed using `db.dateTrunc('month', qiCol('orderDate'))`). Accepts `{ filters, conditions }`. Same `unique: false` aliases.
|
|
593
|
-
- **`joinLeft`** — embeds an N:1 parent. Real `LEFT JOIN` is added on demand (only when the request has `filters`/`conditions` on the parent or uses 2-part `orderBy` on this alias). Aliases must be declared with `unique: true`. Accepts `{ filters, conditions, selection }`. Its `filters` accept schema and computed fields only: the parent's `extraFilters` need an alias-aware `extendedCondition`, so they are rejected with a `400` here instead of being silently ignored — use `joinMustExist` on the same relation when you need one.
|
|
594
|
-
|
|
595
|
-
**Dot-notation in `orderBy` and `conditions`**:
|
|
596
|
-
|
|
597
|
-
| Form | Source | Example |
|
|
598
|
-
|------|--------|---------|
|
|
599
|
-
| `<field>` | main schema | `orderBy=name ASC` |
|
|
600
|
-
| `<alias>.<field>` | `joinLeft` aliases (`unique: true`) | `orderBy=creator.name ASC` |
|
|
601
|
-
| `<alias>.<fn>.<field>` | `joinGroup` aliases declared in the same body | `orderBy=orders.sum.total DESC`, or `conditions: [{ field: 'orders.count.id', method: 'isGreaterOrEqual', params: [4] }]` |
|
|
602
|
-
|
|
603
|
-
**Querystring** (optional): `orderBy`, `page`, `itemsPerPage`, `computeMin`, `computeMax`, `computeSum`, `computeAvg`
|
|
604
|
-
|
|
605
|
-
> `itemsPerPage` is capped at `maxItemsPerPage` (default `1000`); a larger value returns `400`. A search with no `page`/`itemsPerPage` returns at most that many rows (no full-table dumps). See [Request limits](#request-limits).
|
|
606
|
-
|
|
607
|
-
**Response:**
|
|
608
|
-
|
|
609
|
-
```json
|
|
610
|
-
{
|
|
611
|
-
"table": "customer",
|
|
612
|
-
"main": [{ "id": 1, "name": "Mario", "email": "m@test.it" }],
|
|
613
|
-
"joinLeft": { "creator": [{ "id": 7, "name": "Alice", "email": "a@x.it" }] },
|
|
614
|
-
"joinMultiple": { "orders": [{ "id": 10, "customerId": 1, "total": 50 }] },
|
|
615
|
-
"joinGroup": {
|
|
616
|
-
"orders": {
|
|
617
|
-
"sum": { "total": 300 },
|
|
618
|
-
"count": { "id": 2 },
|
|
619
|
-
"rows": [{ "by": "completed", "sum_total": 300, "count_id": 2 }]
|
|
620
|
-
}
|
|
621
|
-
},
|
|
622
|
-
"pagination": {
|
|
623
|
-
"total": 25,
|
|
624
|
-
"pages": 3,
|
|
625
|
-
"computed": { "min": { "id": 1 }, "max": { "id": 100 } },
|
|
626
|
-
"paginator": { "page": 1, "itemsPerPage": 20 }
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
```
|
|
630
|
-
|
|
631
|
-
`joinLeft`, `joinMultiple`, `joinGroup`, and `pagination` appear only when requested. A simple `{}` body returns `{ table, main }`. `pagination.computed` appears only if `computeMin`/`computeMax`/`computeSum`/`computeAvg` are used.
|
|
632
|
-
|
|
633
|
-
### GET /rest/{table}/:id
|
|
634
|
-
|
|
635
|
-
Returns `{ main: { ... } }` or 404.
|
|
636
|
-
|
|
637
|
-
> **Composite primary keys:** `GET /rest/:id`, `DELETE /rest/:id` and `POST /bulk/:table/delete`
|
|
638
|
-
> address a record by a single PK value, so they are **not registered** for tables whose
|
|
639
|
-
> primary key spans multiple columns (a match on the first column alone could hit many rows —
|
|
640
|
-
> for the deletes, destructively). Explicitly listing one of these operations in `operations`
|
|
641
|
-
> for such a table throws at startup. Use `search` with all PK fields in the filters, `update`
|
|
642
|
-
> (which matches every PK column), or a custom route.
|
|
643
|
-
|
|
644
|
-
### POST /rest/{table}
|
|
645
|
-
|
|
646
|
-
**Body:**
|
|
647
|
-
|
|
648
|
-
```json
|
|
649
|
-
{
|
|
650
|
-
"main": { "name": "Mario", "email": "m@test.it" },
|
|
651
|
-
"secondaries": {
|
|
652
|
-
"orders": [{ "total": 50, "status": "pending" }]
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
```
|
|
656
|
-
|
|
657
|
-
`secondaries` keys are the **alias** declared in `allowedWriteJoins`. FK fields are auto-filled from the inserted main record.
|
|
658
|
-
|
|
659
|
-
> **Owned child tables (translations, `*_info` details): use a writeJoin, not a standalone table.**
|
|
660
|
-
> 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:
|
|
661
|
-
> ```typescript
|
|
662
|
-
> // on the parent (product) table:
|
|
663
|
-
> allowedWriteJoins: [
|
|
664
|
-
> buildRelation(SchemaProduct, 'id', SchemaProductInfo, 'productId', { alias: 'translations' }),
|
|
665
|
-
> ],
|
|
666
|
-
> upsertMap: buildUpsertRules(
|
|
667
|
-
> buildUpsertRule(SchemaProductInfo, ['productId', 'lang']), // composite conflict key
|
|
668
|
-
> ),
|
|
669
|
-
> // → PUT /rest/product { "main": {...}, "secondaries": { "translations": [{ "lang": "en", "name": "Bike" }] } }
|
|
670
|
-
> ```
|
|
671
|
-
> 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 (see the note under GET).
|
|
672
|
-
|
|
673
|
-
**Response (201):** `{ main: { ... }, secondaries: { ... } }`
|
|
674
|
-
|
|
675
|
-
### PUT /rest/{table}
|
|
676
|
-
|
|
677
|
-
**Body:**
|
|
678
|
-
|
|
679
|
-
```json
|
|
680
|
-
{
|
|
681
|
-
"main": { "id": 1, "name": "Updated Name" },
|
|
682
|
-
"secondaries": { "orders": [{ "total": 75 }] },
|
|
683
|
-
"deletions": { "orders": [{ "id": 10 }] }
|
|
684
|
-
}
|
|
685
|
-
```
|
|
686
|
-
|
|
687
|
-
`main` must include the primary key. `secondaries` and `deletions` are optional.
|
|
688
|
-
|
|
689
|
-
### DELETE /rest/{table}/:id
|
|
690
|
-
|
|
691
|
-
**Response (200):** `{ main: { <pk>: ... } }` (PK-only, like all write responses), or `404` if
|
|
692
|
-
the record does not exist.
|
|
693
|
-
|
|
694
|
-
### PUT /bulk/{table}
|
|
695
|
-
|
|
696
|
-
**Body:** Array of `{ main, secondaries?, deletions? }`. All main records are inserted/upserted in a single SQL query.
|
|
697
|
-
|
|
698
|
-
### POST /bulk/{table}/delete
|
|
699
|
-
|
|
700
|
-
**Body:** Array of objects with the PK field, e.g. `[{ "id": 1 }, { "id": 2 }]`. Executes as a single `DELETE WHERE pk IN (...)`.
|
|
701
|
-
|
|
702
|
-
### Validation errors (400)
|
|
703
|
-
|
|
704
|
-
Both schema-level (TypeBox/Ajv) and custom (`validate` / `validateBulk`) errors use the same response shape:
|
|
705
|
-
|
|
706
|
-
```json
|
|
707
|
-
{
|
|
708
|
-
"statusCode": 400,
|
|
709
|
-
"error": "Bad Request",
|
|
710
|
-
"message": "Validation failed",
|
|
711
|
-
"fields": [
|
|
712
|
-
{ "path": "main.email", "code": "format", "message": "must match format \"email\"" },
|
|
713
|
-
{ "path": "name", "code": "required", "message": "is required" }
|
|
714
|
-
]
|
|
715
|
-
}
|
|
716
|
-
```
|
|
717
|
-
|
|
718
|
-
Custom validators return tuples `[field, code]` or `[field, code, message]` — `message` defaults to `code` if omitted. `validateBulk` replaces per-item `validate` in bulk-upsert requests.
|
|
719
|
-
|
|
720
|
-
## Multi-Tenant
|
|
721
|
-
|
|
722
|
-
Automatic row-level isolation on all CRUD operations. Configure once, no code in handlers.
|
|
723
|
-
|
|
724
|
-
```typescript
|
|
725
|
-
await app.register(fastifyAutoSqlApi, {
|
|
726
|
-
DbTables: dbTables,
|
|
727
|
-
getTenantId: (request) => request.user?.organizationId ?? null,
|
|
728
|
-
});
|
|
729
|
-
```
|
|
730
|
-
|
|
731
|
-
When `getTenantId` returns `null`, no filtering is applied (admin mode).
|
|
732
|
-
|
|
733
|
-
### Direct tenant (column on the table)
|
|
734
|
-
|
|
735
|
-
```typescript
|
|
736
|
-
defineTable({
|
|
737
|
-
primary: 'id',
|
|
738
|
-
...exportTableInfo(SchemaCustomer),
|
|
739
|
-
tenantScope: { column: 'organization_id' },
|
|
740
|
-
});
|
|
741
|
-
```
|
|
742
|
-
|
|
743
|
-
### Indirect tenant (via parent table)
|
|
744
|
-
|
|
745
|
-
```typescript
|
|
746
|
-
defineTable({
|
|
747
|
-
primary: 'id',
|
|
748
|
-
...exportTableInfo(SchemaOrder),
|
|
749
|
-
tenantScope: {
|
|
750
|
-
column: 'organization_id',
|
|
751
|
-
through: { schema: SchemaCustomer, localField: 'customer_id', foreignField: 'id' },
|
|
752
|
-
},
|
|
753
|
-
});
|
|
754
|
-
```
|
|
755
|
-
|
|
756
|
-
Tables without `tenantScope` are unaffected.
|
|
757
|
-
|
|
758
|
-
### Isolation guarantees on writes
|
|
759
|
-
|
|
760
|
-
Tenant isolation is enforced on write paths, not just reads:
|
|
761
|
-
|
|
762
|
-
- **Upsert conflicts are ownership-checked.** On a tenant-scoped table with an `upsertMap`, an
|
|
763
|
-
upsert (`POST /rest/:table` or `PUT /bulk/:table`) whose conflict key matches a row owned by
|
|
764
|
-
another tenant is rejected with `403` — it cannot overwrite or re-assign that row.
|
|
765
|
-
- **The tenant link cannot be changed to another tenant.** For direct scopes the tenant column is
|
|
766
|
-
stripped from update payloads. For indirect scopes, changing the through-FK (`localField`) is
|
|
767
|
-
allowed only to a value the caller owns; otherwise the update returns `403`.
|
|
768
|
-
|
|
769
|
-
## Swagger
|
|
770
|
-
|
|
771
|
-
Enabled by passing `swagger: true` (or a config object) to the plugin options. Requires `@fastify/swagger` and `@fastify/swagger-ui` as peer dependencies.
|
|
772
|
-
|
|
773
|
-
```bash
|
|
774
|
-
npm install @fastify/swagger @fastify/swagger-ui
|
|
775
|
-
```
|
|
776
|
-
|
|
777
|
-
```typescript
|
|
778
|
-
await app.register(fastifyAutoSqlApi, {
|
|
779
|
-
DbTables: dbTables,
|
|
780
|
-
swagger: {
|
|
781
|
-
title: 'My API',
|
|
782
|
-
description: 'Auto-generated CRUD API',
|
|
783
|
-
version: '1.0.0',
|
|
784
|
-
routePrefix: '/docs',
|
|
785
|
-
},
|
|
786
|
-
});
|
|
787
|
-
```
|
|
788
|
-
|
|
789
|
-
If the swagger packages are not installed, the plugin logs a warning and continues without Swagger.
|
|
790
|
-
|
|
791
|
-
## Granular Composition
|
|
792
|
-
|
|
793
|
-
Instead of registering the all-in-one plugin, you can register individual route plugins for more control:
|
|
794
|
-
|
|
795
|
-
```typescript
|
|
796
|
-
import {
|
|
797
|
-
searchRoutes, getRoutes, insertRoutes, updateRoutes,
|
|
798
|
-
deleteRoutes, bulkUpsertRoutes, bulkDeleteRoutes, setupSwagger,
|
|
799
|
-
} from 'fastify-auto-sqlapi';
|
|
800
|
-
|
|
801
|
-
// Read-only API
|
|
802
|
-
await app.register(async (instance) => {
|
|
803
|
-
await setupSwagger(instance, { swagger: true });
|
|
804
|
-
const opts = { DbTables: dbTables };
|
|
805
|
-
await instance.register(searchRoutes, opts);
|
|
806
|
-
await instance.register(getRoutes, opts);
|
|
807
|
-
}, { prefix: '/public' });
|
|
808
|
-
|
|
809
|
-
// Full CRUD with auth
|
|
810
|
-
await app.register(async (instance) => {
|
|
811
|
-
const opts = { DbTables: dbTables, onRequests: [authMiddleware] };
|
|
812
|
-
await instance.register(searchRoutes, opts);
|
|
813
|
-
await instance.register(getRoutes, opts);
|
|
814
|
-
await instance.register(insertRoutes, opts);
|
|
815
|
-
await instance.register(updateRoutes, opts);
|
|
816
|
-
await instance.register(deleteRoutes, opts);
|
|
817
|
-
await instance.register(bulkUpsertRoutes, opts);
|
|
818
|
-
await instance.register(bulkDeleteRoutes, opts);
|
|
819
|
-
}, { prefix: '/admin' });
|
|
820
|
-
```
|
|
821
|
-
|
|
822
|
-
## LLM / agent clients
|
|
823
|
-
|
|
824
|
-
The plugin is a natural enforcement layer for an agent that operates on your data through
|
|
825
|
-
chat: whatever requests the LLM invents, they pass through the same tenant scoping,
|
|
826
|
-
`readExclude`, `operations` whitelist, validation and request caps as any HTTP client —
|
|
827
|
-
direct-DB firepower, backoffice constraints. Three pieces make this practical:
|
|
828
|
-
|
|
829
|
-
**1. The grammar** — [`AGENTS_FRONTEND.md`](./AGENTS_FRONTEND.md) (ships in the npm package)
|
|
830
|
-
is a compact, LLM-oriented reference of how to call the API: search with all four join
|
|
831
|
-
families, ordering, pagination, writes, error shapes. Put it in the system prompt.
|
|
832
|
-
|
|
833
|
-
**2. The vocabulary** — enable the manifest endpoint to describe *this deployment's* tables:
|
|
834
|
-
|
|
835
|
-
```typescript
|
|
836
|
-
await app.register(fastifyAutoSqlApi, {
|
|
837
|
-
DbTables: dbTables,
|
|
838
|
-
agentManifest: true, // GET {prefix}/agent/manifest (JSON) + /agent/manifest.md
|
|
839
|
-
});
|
|
840
|
-
```
|
|
841
|
-
|
|
842
|
-
`GET /agent/manifest.md` returns a compact markdown block per table — fields with
|
|
843
|
-
type/required/nullable, enabled operations, join aliases (with direction), computed fields,
|
|
844
|
-
extra filters — always in sync with the running config, behind the same `onRequests` auth.
|
|
845
|
-
Fetch it at session start: grammar + vocabulary is everything the model needs. The same data
|
|
846
|
-
is available programmatically via `buildAgentManifest(dbTables)` /
|
|
847
|
-
`renderAgentManifestMd(manifest)`.
|
|
848
|
-
|
|
849
|
-
**3. The guardrails** — writes are often too dangerous to hand to a model. Register a
|
|
850
|
-
read-only surface with granular composition and give the agent only that:
|
|
851
|
-
|
|
852
|
-
```typescript
|
|
853
|
-
import { searchRoutes, getRoutes, agentManifestRoutes } from 'fastify-auto-sqlapi';
|
|
854
|
-
|
|
855
|
-
await app.register(async (instance) => {
|
|
856
|
-
const opts = { DbTables: dbTables, onRequests: [agentAuth] };
|
|
857
|
-
await instance.register(searchRoutes, opts);
|
|
858
|
-
await instance.register(getRoutes, opts);
|
|
859
|
-
await instance.register(agentManifestRoutes, opts);
|
|
860
|
-
}, { prefix: '/agent' }); // reads only: no insert/update/delete routes exist here
|
|
861
|
-
```
|
|
862
|
-
|
|
863
|
-
**Validation strategy** — two options, per table or per deployment:
|
|
864
|
-
|
|
865
|
-
- *Loose tool + retry loop* (recommended default): expose a generic tool
|
|
866
|
-
(`table` as enum from the manifest, `body` as free object) and let the plugin validate.
|
|
867
|
-
A failed request returns a structured 400 with `fields: [{path, code, message}]` — feed
|
|
868
|
-
it back to the model and it self-corrects in one round trip. Cheap prompts, no schema
|
|
869
|
-
duplication.
|
|
870
|
-
- *Strict tools*: `agentToolSchemas(dbTables, 'customer')` returns the exact JSON Schemas
|
|
871
|
-
the routes validate with (body + querystring for search, bodies for writes, only for the
|
|
872
|
-
operations enabled on that table) — plug them into provider-side tool definitions when
|
|
873
|
-
you want invalid calls rejected before they leave the model. Costs prompt size; best for
|
|
874
|
-
a few hot tables.
|
|
875
|
-
|
|
876
|
-
## Design Decisions
|
|
877
|
-
|
|
878
|
-
Deliberate, non-obvious choices — open-by-default, non-transactional bulk operations,
|
|
879
|
-
always-updatable fields, raw DB errors, insert-pipeline ordering — are recorded as
|
|
880
|
-
[Architecture Decision Records in `docs/adr/`](./docs/adr/README.md), each with its
|
|
881
|
-
rationale and the alternatives that were rejected. Read them before filing an issue that
|
|
882
|
-
proposes changing one of these behaviors.
|
|
883
|
-
|
|
884
|
-
## Conventions
|
|
885
|
-
|
|
886
|
-
- **camelCase everywhere in the API** — requests, responses, `validate`, and all hooks (`beforeInsert`/`afterInsert`, `beforeUpdate`/`afterUpdate`, `beforeDelete`/`afterDelete`, `beforeBulkDelete`/`afterBulkDelete`) use schema field names
|
|
887
|
-
- **Conversion to DB column format is automatic** via `colMap` — supports both snake_case and camelCase DB columns (e.g. betterauth-style)
|
|
888
|
-
- **Aliases identify joins** — declared in `buildRelation({ alias })`, used as keys in request/response/`secondaries`/dotted notation
|
|
889
|
-
- **`joinMustExist` / `joinMultiple` / `joinGroup`** are 1:N (child→main) and use side queries / EXISTS / correlated subqueries — no row duplication
|
|
890
|
-
- **`joinLeft`** is N:1 (parent→main) and adds a real `LEFT JOIN` on demand (only when filtering/ordering by parent)
|
|
891
|
-
- **All response fields are Optional** — response schemas use `Type.Partial` since `RETURNING *` may return any subset
|
|
892
|
-
- **Nullable columns use `Nullable(T)`** — the generator emits the JSON-Schema type-array form (`type: ['integer', 'null']`) via the exported `Nullable()` helper. This is deliberate: a bare `Type.Optional(T)` serializes NULL as `0`/`""`, and a `Type.Union([T, Type.Null()])` gets corrupted by Ajv's default type coercion (`null` ↔ `0`/`""` through the branches). In `filters`, an explicit `null` filters by `IS NULL`
|
|
893
|
-
- **The `pg` driver returns `numeric`/`int8` as strings** and parses timestamps into local-timezone `Date`s — driver defaults the plugin deliberately does not override (global state). See "PostgreSQL driver type parsers" in AGENTS_BACKEND.md for the recommended one-time setup
|
|
894
|
-
|
|
895
|
-
## Re-exports
|
|
896
|
-
|
|
897
|
-
The package re-exports commonly needed utilities so you don't need to install them separately:
|
|
898
|
-
|
|
899
|
-
```typescript
|
|
900
|
-
import {
|
|
901
|
-
Type, // from @sinclair/typebox
|
|
902
|
-
type Static, // from @sinclair/typebox
|
|
903
|
-
ConditionBuilder, // from node-condition-builder
|
|
904
|
-
Expression, // from node-condition-builder
|
|
905
|
-
|
|
906
|
-
// Table configuration
|
|
907
|
-
defineTable,
|
|
908
|
-
exportTableInfo,
|
|
909
|
-
buildRelation,
|
|
910
|
-
buildUpsertRule,
|
|
911
|
-
buildUpsertRules,
|
|
912
|
-
Nullable, // type-array nullable schema (see Conventions)
|
|
913
|
-
|
|
914
|
-
// DB layer
|
|
915
|
-
QueryClient, // raw SQL query helper
|
|
916
|
-
createQueryClient, // factory with dialect string
|
|
917
|
-
pgQueryable, // pg pool adapter
|
|
918
|
-
mysqlQueryable, // mysql2 pool adapter
|
|
919
|
-
|
|
920
|
-
// Programmatic high-level API
|
|
921
|
-
createSqlApi, // standalone SqlApi (for scripts/tests)
|
|
922
|
-
setupSwagger, // manual Swagger registration
|
|
923
|
-
} from 'fastify-auto-sqlapi';
|
|
924
|
-
|
|
925
|
-
import type {
|
|
926
|
-
ValidationError, // [field, code] | [field, code, message]
|
|
927
|
-
ValidatorFn,
|
|
928
|
-
BulkValidatorFn,
|
|
929
|
-
JoinDefinition,
|
|
930
|
-
JoinRefFilter,
|
|
931
|
-
JoinFetchRequest,
|
|
932
|
-
JoinGroupRequest,
|
|
933
|
-
SearchCondition,
|
|
934
|
-
ConditionMethod,
|
|
935
|
-
} from 'fastify-auto-sqlapi';
|
|
936
|
-
```
|
|
937
|
-
|
|
938
|
-
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.
|
|
315
|
+
Three properties make the generated configuration reviewable, by a person or by an agent: a
|
|
316
|
+
table is **one local `defineTable()` call** with nothing to wire across files; there is **one
|
|
317
|
+
naming convention** (camelCase in requests, responses, hooks and validators — the mapping to DB
|
|
318
|
+
columns is automatic); and a `400` carries `fields: [{ path, code, message }]`, so a failing
|
|
319
|
+
request says exactly what to fix.
|
|
939
320
|
|
|
940
321
|
## Requirements
|
|
941
322
|
|
|
942
|
-
- Node.js >= 18
|
|
943
|
-
-
|
|
944
|
-
- One of:
|
|
945
|
-
- **PostgreSQL** + `pg` + `@fastify/postgres`
|
|
946
|
-
- **MySQL** / **MariaDB** + `mysql2`
|
|
323
|
+
- Node.js >= 18, Fastify >= 5 (peer dependency)
|
|
324
|
+
- **PostgreSQL** + `pg` + `@fastify/postgres`, or **MySQL** / **MariaDB** + `mysql2`
|
|
947
325
|
- Optional: `@fastify/swagger` + `@fastify/swagger-ui` for Swagger UI
|
|
948
326
|
|
|
949
327
|
## License
|