fastify-auto-sqlapi 0.2.1 → 0.2.3
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 +265 -7
- package/AGENTS_FRONTEND.md +3 -3
- package/BREAKING_CHANGES.md +156 -0
- package/CHANGELOG.md +265 -19
- package/README.md +260 -983
- 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 +4 -1
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +15 -3
- package/dist/lib/cli/mysql-introspect.d.ts +28 -0
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +49 -16
- 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/engine/bulk/bulk-upsert.js +2 -2
- 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 +5 -2
- 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 +14 -10
- 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 +9 -3
- 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 +27 -28
- 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 +123 -2
- package/dist/lib/tenant.d.ts +2 -2
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +7 -1
- 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.js +2 -2
- package/dist/routes/auto/route-helpers.js +1 -1
- 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 +24 -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/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/adr/0004-updates-always-open.md +2 -1
- 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 +5 -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.
|
|
15
|
+
|
|
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.**
|
|
6
19
|
|
|
7
|
-
|
|
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
|
|
@@ -45,111 +62,106 @@ And multi-tenancy is one plugin option (`getTenantId: (req) => req.user?.organiz
|
|
|
45
62
|
// → 403 Forbidden — cross-tenant references are rejected server-side
|
|
46
63
|
```
|
|
47
64
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
## Not an ORM, not GraphQL — a third thing
|
|
51
|
-
|
|
52
|
-
- **Not an ORM.** An ORM is a library *your* code uses to talk to the database — you still hand-write every endpoint on top of it. This plugin generates the endpoints themselves. And there is no ORM underneath either: no models, no migrations, no second schema to keep in sync. The database is the single source of truth (the CLI reads it from `information_schema`), and every request runs as plain parameterized SQL you can read with `debug: true`.
|
|
53
|
-
- **Not GraphQL.** GraphQL buys client-driven flexibility with a heavy toolchain: a schema layer, hand-written resolvers (and their N+1 traps), mutations written one by one, client libraries, and query-cost analysis to stop hostile requests. This plugin covers what most projects actually reach for GraphQL for — filter, paginate, join related data, aggregate, in one round trip — with a fixed JSON grammar over plain REST: curl-able, Swagger-documented, bounded by design (join depth is fixed, page and bulk sizes are capped). The write side — nested children, upserts, bulk — is generated too, which GraphQL never gives you for free. What you give up: arbitrarily deep nesting, per-field selection on the main table, subscriptions. If you need those, you need GraphQL; most CRUD backends don't.
|
|
54
|
-
- **Not a hosted black box.** PostgREST / Hasura / Supabase give you an instant API as a separate service, configured from the outside. This is a plugin inside your own app: hooks, validation and auth are TypeScript functions in your codebase, and you can always drop down to `app.sqlApi.*` or raw SQL in a custom route — same engine, no lock-in.
|
|
55
|
-
|
|
56
|
-
## Features
|
|
57
|
-
|
|
58
|
-
- **Zero boilerplate** — 7 endpoints per exposed table; related tables can stay behind joins and nested writes instead of getting endpoints of their own
|
|
59
|
-
- **No ORM** — raw SQL via `pg` + parameterized queries; set-based joins, so no N+1 by construction
|
|
60
|
-
- **Agent-ready** — `AGENTS.md` and the ADRs ship *inside* the npm package, so a coding agent can configure the library without reading the source or guessing intent
|
|
61
|
-
- **TypeBox validation** — request/response schemas auto-generated from your DB
|
|
62
|
-
- **Joins** — four explicit families (`joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`) with alias support
|
|
63
|
-
- **Computed fields** — declare virtual fields as SQL expressions in `defineTable`; usable like schema fields in filters/orderBy/conditions, and opt-in projected in the response via `selectComputed`. Covers JSON extraction, derived columns, dialect-aware date bucketing
|
|
64
|
-
- **Bulk operations** — batch insert/upsert/delete in single queries
|
|
65
|
-
- **Multi-tenant** — automatic row-level isolation, zero code in handlers
|
|
66
|
-
- **Validation** — structured field-level validation with cross-entity support
|
|
67
|
-
- **Hooks** — full before/after matrix (insert, update, delete, bulk delete) for custom logic
|
|
68
|
-
- **Transactions** — insert/update with secondaries run atomically (rollback on failure)
|
|
69
|
-
- **Swagger UI** — optional, auto-configured from your schemas
|
|
70
|
-
- **Composable** — register all routes or pick only what you need
|
|
71
|
-
|
|
72
|
-
> ⚠️ **Upgrading?** See **[CHANGELOG.md](./CHANGELOG.md)** for what changed in each release, and
|
|
73
|
-
> **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)** for migration guides. If you use `computedFields`
|
|
74
|
-
> with bound values, read the 0.1.6 entry — earlier versions could return wrong rows.
|
|
65
|
+
> ⚠️ **`getTenantId` returning `null`/`undefined` means _admin_ — no filtering, full cross-tenant access.** So "unauthenticated" and "admin" collapse to the same value: if your auth hook is missing or fails, `req.user` is `undefined` and the `?? null` above serves the request as admin. Nothing fails closed. In production, make `getTenantId` (or the auth hook) **reject when there is no authenticated principal**, and reserve `null` for a real superadmin — see the `getTenantId` option docs.
|
|
75
66
|
|
|
67
|
+
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.
|
|
76
68
|
|
|
77
|
-
##
|
|
78
|
-
|
|
79
|
-
|
|
69
|
+
## What you get
|
|
70
|
+
|
|
71
|
+
**Reads** — filters and equality shortcuts · advanced conditions (18 operators) · pagination
|
|
72
|
+
with caps · four explicit relation families (`joinMustExist` for EXISTS filtering,
|
|
73
|
+
`joinMultiple` for child rows, `joinGroup` for aggregations, `joinLeft` for N:1 parents) ·
|
|
74
|
+
aggregations with HAVING-style conditions · ordering by related and computed values · computed
|
|
75
|
+
fields declared as SQL expressions (JSON extraction, derived strings, date bucketing)
|
|
76
|
+
|
|
77
|
+
**Writes** — validated inserts and updates · nested child records with foreign keys filled in
|
|
78
|
+
from the parent · child deletions in the same request · bulk upsert and bulk delete ·
|
|
79
|
+
transactions, so a write and its children commit or roll back together
|
|
80
|
+
|
|
81
|
+
**Production** — multi-tenant row isolation from one `getTenantId`, enforced on reads *and*
|
|
82
|
+
writes · hooks before and after every operation, plus `afterRead` · field-level and
|
|
83
|
+
cross-entity validation returning structured `400`s · per-table route whitelists · read and
|
|
84
|
+
write column visibility · Swagger from the same schemas · request-size and pagination caps ·
|
|
85
|
+
plain parameterized SQL you can print with `debug: true`
|
|
86
|
+
|
|
87
|
+
Views are table configs like any other, materialized views included. And when generated CRUD
|
|
88
|
+
stops being the right abstraction, call `app.sqlApi.*` from a hand-written route: you keep the
|
|
89
|
+
filters, joins, tenant scoping, hooks and validation, and drop only the assumption that the
|
|
90
|
+
endpoint looks like a table.
|
|
91
|
+
|
|
92
|
+
### N+1 is structurally absent
|
|
93
|
+
|
|
94
|
+
A request runs the main select plus one query per requested join alias — a number fixed by the
|
|
95
|
+
request, **never by the number of rows returned**. There are no lazy relations to accidentally
|
|
96
|
+
loop over, so N+1 is not something you have to remember to avoid.
|
|
97
|
+
|
|
98
|
+
## Is this for you?
|
|
99
|
+
|
|
100
|
+
**Yes, if you are building an internal tool** — a back-office, an admin panel, a management
|
|
101
|
+
system where superadmin, admin and tenant-admin roles need full control over the domain. There
|
|
102
|
+
the database schema *is* the domain model, and whoever changes a column is whoever changes the
|
|
103
|
+
screen that shows it. A hand-written CRUD layer in the middle has no reader: it exists only to
|
|
104
|
+
be kept in sync with the thing it translates.
|
|
105
|
+
|
|
106
|
+
Concretely, it fits when:
|
|
107
|
+
|
|
108
|
+
- most of your endpoints are "list with filters", "get one", "save", "save many"
|
|
109
|
+
- the people who change the schema also ship the client
|
|
110
|
+
- you want tenant isolation, validation, pagination caps and Swagger without writing them per table
|
|
111
|
+
- the interesting logic is *rules* (who may do what, what must be valid) rather than *shapes*
|
|
112
|
+
- the back-office already exists and you want to stop hand-writing its next twenty endpoints,
|
|
113
|
+
without restructuring the twenty you already have
|
|
114
|
+
|
|
115
|
+
**No, if you are publishing an API you do not control the consumers of.** A public or
|
|
116
|
+
third-party API — a contract with a version number and a deprecation policy, clients you cannot
|
|
117
|
+
redeploy alongside your schema — needs a surface whose stability outlives your refactors. That
|
|
118
|
+
indirection is worth paying for there, and generating it from the schema is the wrong default.
|
|
119
|
+
Also a poor fit if your endpoints are mostly workflows rather than records, or if your data
|
|
120
|
+
model is deliberately nothing like your API model.
|
|
121
|
+
|
|
122
|
+
### It drops into a codebase that already exists
|
|
123
|
+
|
|
124
|
+
You do not have to adopt anything to try it. There is no schema file that must describe your
|
|
125
|
+
whole database first, no migration tool to take over, no repository layout to conform to —
|
|
126
|
+
which is what makes this different from adding an ORM to a project that already runs.
|
|
127
|
+
|
|
128
|
+
- **One table is a valid installation.** `sqlapi-generate-schema --tables invoice` and
|
|
129
|
+
`sqlapi-generate-tables invoice` generate for that table alone; `DbTables` contains only what
|
|
130
|
+
you put in it, and the rest of your database stays invisible to the plugin.
|
|
131
|
+
- **It never writes to your database.** Not a row, not a column: generation is `SELECT`s
|
|
132
|
+
against `information_schema`, and at runtime the plugin issues no DDL and runs no migrations.
|
|
133
|
+
Your schema is managed by whatever manages it today.
|
|
134
|
+
- **It uses the connection you already have.** The plugin reads `fastify.pg` or
|
|
135
|
+
`fastify.mysql` off the instance — if your app already registers `@fastify/postgres`, there is
|
|
136
|
+
nothing to wire.
|
|
137
|
+
- **It stays inside its prefix.** `prefix: '/api/auto'` and your existing routes never meet it.
|
|
138
|
+
You can also register only the route plugins you want (`searchRoutes`, `getRoutes`, …), or
|
|
139
|
+
mount the same tables twice under different prefixes with different auth.
|
|
140
|
+
- **Generated files go where you say** (`outputDir`), and the `Table*.ts` files are yours: the
|
|
141
|
+
generator never overwrites one that exists, so re-running it only adds files for new tables.
|
|
142
|
+
|
|
143
|
+
So a realistic first step on an existing back-office is: generate two tables, register the
|
|
144
|
+
plugin under a new prefix behind your existing auth hook, move one list screen onto it, and
|
|
145
|
+
leave everything else exactly where it is. `operations` keeps any table off the auto routes, and
|
|
146
|
+
a table with no routes at all can still be reached through a join or written as a nested child.
|
|
147
|
+
Expose the CRUD that is CRUD, hand-write the rest.
|
|
148
|
+
|
|
149
|
+
## Get a REST API in 60 seconds
|
|
80
150
|
|
|
81
151
|
```bash
|
|
82
152
|
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
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
### 3. Generate schemas from your database
|
|
124
|
-
|
|
125
|
-
```bash
|
|
126
|
-
npx sqlapi-generate-schema # all tables
|
|
127
|
-
npx sqlapi-generate-schema --tables customer,order # only some
|
|
128
|
-
npx sqlapi-generate-schema --dialect mysql # override the config dialect
|
|
153
|
+
# MySQL / MariaDB: npm install fastify-auto-sqlapi fastify mysql2
|
|
129
154
|
```
|
|
130
155
|
|
|
131
|
-
|
|
132
|
-
`<outputDir>/schemas/` (e.g. `SchemaCustomer.ts`, `SchemaOrder.ts`). These files are
|
|
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
|
|
156
|
+
Create `sqlapi.config.ts` in the project root (used by the CLI only, not at runtime), then:
|
|
142
157
|
|
|
143
158
|
```bash
|
|
144
|
-
npx sqlapi-generate-
|
|
159
|
+
npx sqlapi-generate-schema # one TypeBox schema per table → <outputDir>/schemas/
|
|
160
|
+
npx sqlapi-generate-tables --all # one defineTable() template per table → <outputDir>/tables/
|
|
145
161
|
```
|
|
146
162
|
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
163
|
+
Schema files are regenerated on every run and should not be edited. **Table files are yours**:
|
|
164
|
+
the generator never overwrites one that exists, so re-running only adds files for new tables.
|
|
153
165
|
|
|
154
166
|
```typescript
|
|
155
167
|
import Fastify from 'fastify';
|
|
@@ -158,901 +170,166 @@ import { fastifyAutoSqlApi } from 'fastify-auto-sqlapi';
|
|
|
158
170
|
import { dbTables } from './src/db/tables/dbTables.js';
|
|
159
171
|
|
|
160
172
|
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
|
-
|
|
173
|
+
await app.register(fastifyPostgres, { connectionString: 'postgres://user:pass@localhost:5432/mydb' });
|
|
174
|
+
await app.register(fastifyAutoSqlApi, { DbTables: dbTables, swagger: true, prefix: '/api' });
|
|
172
175
|
await app.listen({ port: 3000 });
|
|
173
176
|
```
|
|
174
177
|
|
|
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
|
-
|
|
178
|
+
For a table called `customer` you now have:
|
|
179
|
+
|
|
180
|
+
| Method | URL | |
|
|
181
|
+
|--------|-----|---|
|
|
182
|
+
| `POST` | `/api/search/customer` | search with filters, pagination, joins, aggregations |
|
|
183
|
+
| `GET` | `/api/rest/customer/:id` | get one by primary key |
|
|
184
|
+
| `POST` | `/api/rest/customer` | insert (+ nested children) |
|
|
185
|
+
| `PUT` | `/api/rest/customer` | update (+ nested children and deletions) |
|
|
186
|
+
| `DELETE` | `/api/rest/customer/:id` | delete by primary key |
|
|
187
|
+
| `PUT` | `/api/bulk/customer` | bulk upsert |
|
|
188
|
+
| `POST` | `/api/bulk/customer/delete` | bulk delete |
|
|
189
|
+
|
|
190
|
+
Search is a `POST` because the filters travel as JSON in the body. Every option of `defineTable()` and of `register()` is documented in
|
|
191
|
+
[AGENTS_BACKEND.md](./AGENTS_BACKEND.md); the full request grammar is in
|
|
192
|
+
[AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md).
|
|
193
|
+
|
|
194
|
+
## Why this approach
|
|
195
|
+
|
|
196
|
+
The first objection is always that this couples the API to the database. It does, deliberately,
|
|
197
|
+
and the answer is worth stating precisely.
|
|
198
|
+
|
|
199
|
+
**A hand-written CRUD layer over the same tables is coupled to the same schema** — it just
|
|
200
|
+
retypes it. Rename a column there and you edit the model, the DTO, the mapper, the validator,
|
|
201
|
+
the Swagger annotation and the client: six places instead of one, and the coupling is still
|
|
202
|
+
there. What that layer really buys is an indirection that lets the contract stay still while the
|
|
203
|
+
schema moves — worth wanting, paid for continuously, cashed in rarely. This plugin makes the
|
|
204
|
+
opposite bet, and the bet is the point.
|
|
205
|
+
|
|
206
|
+
**The coupling is checked, not implied.** Schemas are generated from `information_schema`, so a
|
|
207
|
+
schema change that breaks the API breaks it when you regenerate and compile. A hand-written
|
|
208
|
+
mapper nobody remembered to update fails in production instead.
|
|
209
|
+
|
|
210
|
+
**And it is a default, not a fact.** Your schema defines the shape; your code defines the rules.
|
|
211
|
+
The shape you expose is not forced to be the shape you store — there is a specific lever for
|
|
212
|
+
each way they need to differ:
|
|
213
|
+
|
|
214
|
+
| To decouple | Use |
|
|
215
|
+
|---|---|
|
|
216
|
+
| a column must never be read | `readExclude` |
|
|
217
|
+
| a column must never be written | `writeExclude` |
|
|
218
|
+
| a column must not exist for the API at all | trim it out of the Schema |
|
|
219
|
+
| the API type must be stricter than the column | `schemaOverrides` |
|
|
220
|
+
| the value is derived, not stored | `computedFields` |
|
|
221
|
+
| the stored representation is not the API one | `afterRead` (decrypt, unpack, rescale) |
|
|
222
|
+
| a table must not have all seven routes | `operations` |
|
|
223
|
+
| a relation must be named for the API, not the DB | the relation `alias` and its `fields` allowlist |
|
|
224
|
+
| a filter has no column behind it | `extraFilters` |
|
|
225
|
+
|
|
226
|
+
When none of them fits, the operation was never CRUD: write the route by hand and call
|
|
227
|
+
`app.sqlApi.*` inside it — you keep filters, joins, tenant scoping, hooks and validation, and
|
|
228
|
+
drop only the assumption that the endpoint looks like a table. Full rationale in
|
|
229
|
+
[ADR 0017](./docs/adr/0017-the-schema-is-the-contract.md).
|
|
230
|
+
|
|
231
|
+
### Not an ORM, not GraphQL — a third thing
|
|
232
|
+
|
|
233
|
+
- **Not an ORM.** An ORM is a library *your* code uses to talk to the database — you still
|
|
234
|
+
hand-write every endpoint on top of it. This generates the endpoints themselves, with no ORM
|
|
235
|
+
underneath: no models, no migrations, no second schema to keep in sync. And no schema file
|
|
236
|
+
that has to describe your whole database before anything works: a Prisma-style `schema.prisma`
|
|
237
|
+
becomes the source of truth and the thing you migrate through, so adopting it is a decision
|
|
238
|
+
about the entire database. Here the database stays the source of truth and generation is
|
|
239
|
+
read-only — the CLI issues `SELECT`s against `information_schema` and nothing else, ever.
|
|
240
|
+
Every request runs as plain parameterized SQL you can read with `debug: true`.
|
|
241
|
+
- **Not GraphQL.** GraphQL buys client-driven flexibility with a schema layer, hand-written
|
|
242
|
+
resolvers and their N+1 traps, mutations one by one, and query-cost analysis to stop hostile
|
|
243
|
+
requests. This covers what most projects reach for GraphQL for — filter, paginate, join,
|
|
244
|
+
aggregate in one round trip — with a fixed JSON grammar over plain REST: curl-able,
|
|
245
|
+
Swagger-documented, bounded by design. The write side is generated too. What you give up:
|
|
246
|
+
arbitrarily deep nesting, per-field selection on the main table, subscriptions.
|
|
247
|
+
- **Not a hosted black box.** PostgREST / Hasura / Supabase give you an API as a separate
|
|
248
|
+
service, configured from outside. This is a plugin inside your own app: hooks, validation and
|
|
249
|
+
auth are TypeScript functions in your codebase, and you can always drop to `app.sqlApi.*` or
|
|
250
|
+
raw SQL in a custom route. No lock-in.
|
|
251
|
+
|
|
252
|
+
Queries are set-based: the main select plus one per requested join alias, **never one per
|
|
253
|
+
returned row**. There are no lazy relations to loop over — N+1 is structurally absent, not
|
|
254
|
+
something you remember to avoid.
|
|
255
|
+
|
|
256
|
+
## Before you ship
|
|
257
|
+
|
|
258
|
+
> ⚠️ **The plugin is open by default.** Registering it with no further configuration exposes
|
|
259
|
+
> **every operation on every table in `DbTables`** — reads *and* writes, bulk delete included —
|
|
260
|
+
> to anyone who can reach the server. That is intentional: the plugin provides the tools and
|
|
261
|
+
> imposes no auth model ([ADR 0002](./docs/adr/0002-open-by-default.md)). Locking it down is
|
|
262
|
+
> yours to do.
|
|
263
|
+
|
|
264
|
+
Five things decide whether a deployment is safe. Each is one option, and each is covered in
|
|
265
|
+
full in [AGENTS_BACKEND.md](./AGENTS_BACKEND.md).
|
|
266
|
+
|
|
267
|
+
**1. Who may call the routes** — `onRequests` runs before every generated route, globally or per
|
|
268
|
+
table. It is where `jwtVerify()` and your role checks go.
|
|
269
|
+
|
|
270
|
+
**2. Which routes exist** — `operations: ['search', 'get']` registers only those; the rest answer
|
|
271
|
+
404. It gates HTTP only: your own code still reaches everything through `app.sqlApi.*`.
|
|
272
|
+
|
|
273
|
+
**3. A declared join is a read grant.** Neither of the two above follows a join: a join is
|
|
274
|
+
resolved inside the *host* table's query, so the target's hooks never run and its `operations`
|
|
275
|
+
whitelist does not apply. A table with `operations: []` is still fully readable through any
|
|
276
|
+
relation pointing at it. `allowedReadJoins` is therefore a security decision — and
|
|
277
|
+
`buildRelation(..., { fields: ['id', 'name'] })` narrows what a relation exposes, fail-closed.
|
|
278
|
+
([ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md),
|
|
279
|
+
[ADR 0011](./docs/adr/0011-join-fields-allowlist.md))
|
|
280
|
+
|
|
281
|
+
**4. Every Schema field is updatable by default.** `excludeFromCreation` is an ergonomics tool
|
|
282
|
+
for inserts, not a security mechanism, and it deliberately does not apply to updates. So
|
|
283
|
+
`isAdmin`, roles, ownership and state columns *will* be accepted by the generated update route
|
|
284
|
+
if they are in the Schema. Whether a field may change is a product decision the plugin cannot
|
|
285
|
+
make for you — encode it in `beforeUpdate` (silent strip) or `validate` (loud 400), or keep the
|
|
286
|
+
privileged transition off the auto routes entirely.
|
|
287
|
+
([ADR 0004](./docs/adr/0004-updates-always-open.md))
|
|
288
|
+
|
|
289
|
+
**5. What leaves the server.** Any error the plugin did not raise itself becomes a bare `500`
|
|
290
|
+
carrying a `requestId` — driver messages name your tables, columns and constraints, and they
|
|
291
|
+
stay in the log. No status code is remapped (a unique violation is a `500`, not a `409`: that
|
|
292
|
+
mapping is product logic, for your `setErrorHandler`). Set `exposeDebugInfo: true` while
|
|
293
|
+
developing to get the driver detail on the wire, additively.
|
|
294
|
+
([ADR 0013](./docs/adr/0013-sanitized-db-errors.md))
|
|
295
|
+
|
|
296
|
+
Two caps are on by default and configurable: `maxItemsPerPage` (1000) bounds a search — and is
|
|
297
|
+
applied as the `LIMIT` even with no paginator, so an empty-body search cannot dump a table — and
|
|
298
|
+
`maxBulkItems` (1000) bounds a bulk array. Two more are fixed and bound the *work* a search asks
|
|
299
|
+
for rather than the rows it returns: 100 `conditions` and 20 `orderBy` parts, each over-limit
|
|
300
|
+
request answered with a 400. Those two are enforced in the engine, so `sqlApi.search()` gets them
|
|
301
|
+
too. Write bodies are `additionalProperties: false`, so the Schema is the
|
|
302
|
+
write whitelist and there is no mass assignment; note that Fastify *strips* an unknown field by
|
|
303
|
+
default rather than rejecting it.
|
|
304
|
+
|
|
305
|
+
## Documentation
|
|
306
|
+
|
|
307
|
+
| | |
|
|
308
|
+
|---|---|
|
|
309
|
+
| [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. |
|
|
310
|
+
| [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. |
|
|
311
|
+
| [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. |
|
|
312
|
+
| [CHANGELOG.md](./CHANGELOG.md) · [BREAKING_CHANGES.md](./BREAKING_CHANGES.md) | What changed, and how to migrate. |
|
|
313
|
+
|
|
314
|
+
The two `AGENTS_*` files are named for their other audience — they ship inside the npm package
|
|
315
|
+
so a coding agent finds them after `npm install` — but they are the reference documentation for
|
|
316
|
+
people too, and they are written to be read. Point an agent at them once:
|
|
202
317
|
|
|
203
318
|
> Read `node_modules/fastify-auto-sqlapi/AGENTS.md` before touching anything under `src/db/`.
|
|
204
319
|
|
|
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 (server-side only — it never changes a response) |
|
|
372
|
-
| `exposeDebugInfo` | `boolean` | No | **Development only.** Attach the driver error (message, `code`, `constraint`, stack) to `500` responses as `debugInfo` (default: `false`) |
|
|
373
|
-
|
|
374
|
-
The plugin picks up the connection from the Fastify instance: `fastify.pg` for PostgreSQL
|
|
375
|
-
(what `@fastify/postgres` decorates) or `fastify.mysql` for MySQL/MariaDB. For MySQL,
|
|
376
|
-
decorate a `mysql2/promise` pool (e.g. via `@fastify/mysql` with `promise: true`, or manually)
|
|
377
|
-
and pass `dialect: 'mysql'` or `'mariadb'`:
|
|
378
|
-
|
|
379
|
-
```typescript
|
|
380
|
-
import mysql from 'mysql2/promise';
|
|
381
|
-
|
|
382
|
-
const pool = mysql.createPool({ host: '127.0.0.1', user: 'myuser', password: '...', database: 'mydb' });
|
|
383
|
-
app.decorate('mysql', pool);
|
|
384
|
-
app.addHook('onClose', async () => { await pool.end(); });
|
|
385
|
-
|
|
386
|
-
await app.register(fastifyAutoSqlApi, { DbTables: dbTables, dialect: 'mysql' });
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
## Security
|
|
390
|
-
|
|
391
|
-
> ⚠️ **The plugin is open by default.** Registering it without any configuration exposes
|
|
392
|
-
> **all operations on all tables in `DbTables`** (read AND write, including bulk delete) to
|
|
393
|
-
> anyone who can reach the server. This is intentional — the plugin provides the tools and
|
|
394
|
-
> does not impose an auth model — but it means **you** are responsible for locking it down
|
|
395
|
-
> before exposing it.
|
|
396
|
-
|
|
397
|
-
Three layers are available, combinable:
|
|
398
|
-
|
|
399
|
-
**1. Authentication / authorization hooks** — `onRequests` runs before every auto-generated
|
|
400
|
-
route (globally or per table):
|
|
401
|
-
|
|
402
|
-
```typescript
|
|
403
|
-
await app.register(fastifyAutoSqlApi, {
|
|
404
|
-
DbTables: dbTables,
|
|
405
|
-
onRequests: [async (request, reply) => {
|
|
406
|
-
await request.jwtVerify(); // or any auth check; throw/reply to block
|
|
407
|
-
}],
|
|
408
|
-
});
|
|
409
|
-
```
|
|
410
|
-
|
|
411
|
-
**2. Per-table operation whitelist** — `operations` limits which routes are registered for a
|
|
412
|
-
table. Omitted = all operations (default). Unlisted operations are not registered at all
|
|
413
|
-
(they answer 404):
|
|
414
|
-
|
|
415
|
-
```typescript
|
|
416
|
-
const TableAuditLog = defineTable({
|
|
417
|
-
primary: 'id',
|
|
418
|
-
...exportTableInfo(SchemaAuditLog),
|
|
419
|
-
operations: ['search', 'get'], // read-only over HTTP: no insert/update/delete/bulk
|
|
420
|
-
});
|
|
421
|
-
```
|
|
422
|
-
|
|
423
|
-
Note: `operations` gates the HTTP routes only. The programmatic `fastify.sqlApi.*` methods
|
|
424
|
-
are always available to your own code.
|
|
425
|
-
|
|
426
|
-
**3. Multi-tenancy** — `getTenantId` + `tenantScope` filter every query by tenant (see
|
|
427
|
-
[Multi-Tenant](#multi-tenant)).
|
|
428
|
-
|
|
429
|
-
### A declared join is a read grant
|
|
430
|
-
|
|
431
|
-
⚠️ **Neither of the first two layers follows a join.** `onRequests` and `operations` are
|
|
432
|
-
properties of the routes generated *for* a table; a join is resolved inside the query of the
|
|
433
|
-
**host** table's route, so the target's hooks never run and its `operations` whitelist does not
|
|
434
|
-
apply. A table with `operations: []` — no HTTP routes at all — is still fully readable through
|
|
435
|
-
any relation pointing at it.
|
|
436
|
-
|
|
437
|
-
So `allowedReadJoins` is a security decision: declaring a relation grants read access to the
|
|
438
|
-
target table under the **host** table's authorization.
|
|
439
|
-
|
|
440
|
-
What does follow a join: `readExclude`, `tenantScope`, and the schema the relation was declared
|
|
441
|
-
with. To expose a table broadly while keeping some columns narrow, give the relation a `fields`
|
|
442
|
-
allowlist:
|
|
443
|
-
|
|
444
|
-
```typescript
|
|
445
|
-
allowedReadJoins: [
|
|
446
|
-
buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', {
|
|
447
|
-
unique: true,
|
|
448
|
-
fields: ['id', 'name'], // everything else on `user` is unreachable here
|
|
449
|
-
}),
|
|
450
|
-
],
|
|
451
|
-
```
|
|
452
|
-
|
|
453
|
-
Every read surface validates against it — `selection`, `filters`, `conditions`, `orderBy`,
|
|
454
|
-
aggregations, and the generated request/response schemas — so `email` is `400 Unknown field`
|
|
455
|
-
everywhere, not just missing from the projection. It is fail-closed: a column added to `user`
|
|
456
|
-
later is not reachable through this relation until it is added to the list.
|
|
457
|
-
|
|
458
|
-
`fields` restricts **reading** only, and only through that relation. It is not a replacement for
|
|
459
|
-
`readExclude` (which hides a column from the table's own routes too) or for `onRequests` (which
|
|
460
|
-
decides who may call them).
|
|
461
|
-
|
|
462
|
-
Full rationale and the rejected alternatives in
|
|
463
|
-
[ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md) and
|
|
464
|
-
[ADR 0011](./docs/adr/0011-join-fields-allowlist.md).
|
|
465
|
-
|
|
466
|
-
### Request limits
|
|
467
|
-
|
|
468
|
-
Two hard caps bound how much work a single request can trigger (defense against accidental or
|
|
469
|
-
malicious resource exhaustion — relevant precisely because the API is open by default):
|
|
470
|
-
|
|
471
|
-
- **Search result size** — `itemsPerPage` above `maxItemsPerPage` (default `1000`) is rejected
|
|
472
|
-
with `400`. When a search omits pagination entirely, the same value is applied as a `LIMIT`, so
|
|
473
|
-
an empty-body `POST /search/:table` can never dump an entire table.
|
|
474
|
-
- **Bulk array size** — the `PUT /bulk/:table` and `POST /bulk/:table/delete` bodies reject arrays
|
|
475
|
-
longer than `maxBulkItems` (default `1000`) at schema validation.
|
|
476
|
-
|
|
477
|
-
Both are configurable in the plugin options. The programmatic `fastify.sqlApi.*` methods are not
|
|
478
|
-
capped — they run your own trusted code.
|
|
479
|
-
|
|
480
|
-
### Error responses don't describe your schema
|
|
481
|
-
|
|
482
|
-
Driver messages name tables, columns and constraints (`duplicate key value violates unique
|
|
483
|
-
constraint "users_email_key"`). Any error the plugin did not raise itself is replaced with a
|
|
484
|
-
bare `500` before it reaches the client:
|
|
485
|
-
|
|
486
|
-
```json
|
|
487
|
-
{ "statusCode": 500, "error": "Internal Server Error", "message": "Internal Server Error", "requestId": "req-3" }
|
|
488
|
-
```
|
|
489
|
-
|
|
490
|
-
`requestId` is the `reqId` Fastify logs on every line, so the entry holding the real error is
|
|
491
|
-
one grep away — without the error itself travelling to the client.
|
|
492
|
-
|
|
493
|
-
Errors the plugin raises are deliberate `4xx` with messages written for clients (validation,
|
|
494
|
-
not found, tenant violations) and are untouched. **No status code is remapped**: a unique
|
|
495
|
-
violation is still a `500`, not a `409` — deciding otherwise is product logic, and this is
|
|
496
|
-
where you do it:
|
|
497
|
-
|
|
498
|
-
```typescript
|
|
499
|
-
app.setErrorHandler((err, request, reply) => {
|
|
500
|
-
if (err.cause?.code === '23505') {
|
|
501
|
-
return reply.status(409).send({ statusCode: 409, error: 'Conflict', message: 'Already exists' });
|
|
502
|
-
}
|
|
503
|
-
reply.send(err);
|
|
504
|
-
});
|
|
505
|
-
```
|
|
506
|
-
|
|
507
|
-
The original error is never lost: it is logged through `request.log.error` and attached as
|
|
508
|
-
`err.cause`. Errors thrown by your own `onRequests` hooks run before the handler and are
|
|
509
|
-
unaffected.
|
|
510
|
-
|
|
511
|
-
**While developing, you probably want the detail on the wire** — an agent or a frontend
|
|
512
|
-
building against the API cannot always read your server log, and one round trip per failure
|
|
513
|
-
adds up. Say so explicitly:
|
|
514
|
-
|
|
515
|
-
```typescript
|
|
516
|
-
await app.register(fastifyAutoSqlApi, {
|
|
517
|
-
DbTables: dbTables,
|
|
518
|
-
exposeDebugInfo: true, // development only — see below
|
|
519
|
-
});
|
|
520
|
-
```
|
|
521
|
-
|
|
522
|
-
```json
|
|
523
|
-
{
|
|
524
|
-
"statusCode": 500, "error": "Internal Server Error", "message": "Internal Server Error",
|
|
525
|
-
"requestId": "req-3",
|
|
526
|
-
"debugInfo": {
|
|
527
|
-
"message": "duplicate key value violates unique constraint \"users_email_key\"",
|
|
528
|
-
"code": "23505", "constraint": "users_email_key", "stack": "…"
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
```
|
|
532
|
-
|
|
533
|
-
It is **additive**: `statusCode`, `error`, `message` and `requestId` keep the shape they have
|
|
534
|
-
in production, so your client-side error handling doesn't fork between environments.
|
|
535
|
-
|
|
536
|
-
Nothing else flips it — not `debug`, not `NODE_ENV`. `debug` is yours to wire (a constant in
|
|
537
|
-
one deployment, an environment variable in another), so the plugin cannot let it decide what
|
|
538
|
-
leaves the server: it writes to your log, which is access-controlled, while a response body is
|
|
539
|
-
not — and these routes are open by default. What a client sees is readable from the
|
|
540
|
-
registration call alone. See [ADR 0013](./docs/adr/0013-sanitized-db-errors.md) and
|
|
541
|
-
[ADR 0006](./docs/adr/0006-raw-db-errors.md).
|
|
542
|
-
|
|
543
|
-
### Write whitelist
|
|
544
|
-
|
|
545
|
-
Insert/update/bulk-upsert bodies are `additionalProperties: false`: only columns present in the
|
|
546
|
-
generated Schema (as narrowed by `schemaOverrides` / `excludeFromCreation`) can be written. The
|
|
547
|
-
Schema is the write whitelist. Trim the generated Schema (or use `excludeFromCreation`) to keep
|
|
548
|
-
sensitive columns out of it.
|
|
549
|
-
|
|
550
|
-
An unexpected field is **stripped, not rejected**: Fastify runs Ajv with `removeAdditional: true`,
|
|
551
|
-
so the request succeeds and the column is simply never written. Mass assignment is prevented
|
|
552
|
-
either way, but do not expect a `400` to tell a client its extra field was ignored.
|
|
553
|
-
|
|
554
|
-
### Field-level update rules are product logic — use the hooks
|
|
555
|
-
|
|
556
|
-
`excludeFromCreation` is an **ergonomics tool for creation**, not a security mechanism: it
|
|
557
|
-
exists so auto-generated values (serial PKs, DB-default timestamps) don't have to be sent —
|
|
558
|
-
or validated — on insert. It deliberately does **not** apply to updates: every field in the
|
|
559
|
-
Schema is updatable by default, because whether a field may change is a product decision the
|
|
560
|
-
plugin cannot make for you (a super admin may legitimately fix an `updatedAt`; in one product
|
|
561
|
-
users may move themselves across tenants, in another nobody may).
|
|
562
|
-
|
|
563
|
-
So watch out for **sensitive flags and ownership fields** — `isAdmin`, `role`, tenant/owner
|
|
564
|
-
columns, state fields: if they are in the Schema, the auto-generated update route will accept
|
|
565
|
-
them. Encode your rules with the tools made for it:
|
|
566
|
-
|
|
567
|
-
```typescript
|
|
568
|
-
// Silent strip: non-admins simply cannot touch the flag
|
|
569
|
-
beforeUpdate: async (db, req, fields) => {
|
|
570
|
-
if (!req.user.isAdmin) delete fields.isAdmin;
|
|
571
|
-
},
|
|
572
|
-
|
|
573
|
-
// Loud 400: touching the flag without permission is an error
|
|
574
|
-
validate: async (db, req, main) => {
|
|
575
|
-
if (main.isAdmin !== undefined && !req.user.isAdmin)
|
|
576
|
-
return [['isAdmin', 'forbidden']];
|
|
577
|
-
return [];
|
|
578
|
-
},
|
|
579
|
-
```
|
|
580
|
-
|
|
581
|
-
For privileged state transitions (promoting an admin, moving a record across tenants) consider
|
|
582
|
-
a dedicated endpoint with its own auth and audit instead of the auto-generated CRUD route —
|
|
583
|
-
`operations` lets you keep the sensitive operation off the auto routes entirely.
|
|
584
|
-
|
|
585
|
-
Note on tenants: when `tenantScope` is active, tenant-scoped callers can never move a record to
|
|
586
|
-
another tenant (the tenant column is enforced server-side); admin callers (`getTenantId` →
|
|
587
|
-
`null`) are unrestricted. That is the isolation contract of the opt-in tenancy feature, not a
|
|
588
|
-
field-level rule.
|
|
589
|
-
|
|
590
|
-
### Read visibility
|
|
591
|
-
|
|
592
|
-
`readExclude` hides columns from every read while leaving writes untouched — the case for a
|
|
593
|
-
password hash or an access token: writable, never readable.
|
|
594
|
-
|
|
595
|
-
```typescript
|
|
596
|
-
readExclude: ['accessToken'],
|
|
597
|
-
```
|
|
598
|
-
|
|
599
|
-
Excluded fields are not selected by search/get, are omitted from read response schemas and from
|
|
600
|
-
the table's default join selection, and cannot be referenced from `filters`, `conditions`,
|
|
601
|
-
`orderBy`, aggregations or an explicit join `selection` (`400`). That last part is the point:
|
|
602
|
-
letting a hidden field be filtered would leak its value by bisection. Primary keys cannot be
|
|
603
|
-
excluded.
|
|
604
|
-
|
|
605
|
-
## API Reference
|
|
606
|
-
|
|
607
|
-
### POST /search/{table}
|
|
608
|
-
|
|
609
|
-
Search with filters, advanced conditions, pagination, ordering, joins, and aggregations.
|
|
610
|
-
|
|
611
|
-
**Request body** (all fields optional):
|
|
612
|
-
|
|
613
|
-
```json
|
|
614
|
-
{
|
|
615
|
-
"filters": { "name": "Mario" },
|
|
616
|
-
"conditions": [
|
|
617
|
-
{ "field": "total", "method": "isGreater", "params": [100] },
|
|
618
|
-
{ "field": "createdAt", "method": "isBetween", "params": ["2024-01-01", "2024-12-31"] }
|
|
619
|
-
],
|
|
620
|
-
"joinMustExist": {
|
|
621
|
-
"orders": {
|
|
622
|
-
"filters": { "status": "completed" },
|
|
623
|
-
"conditions": [{ "field": "total", "method": "isGreater", "params": [50] }]
|
|
624
|
-
}
|
|
625
|
-
},
|
|
626
|
-
"joinMultiple": {
|
|
627
|
-
"orders": {
|
|
628
|
-
"filters": { "status": "completed" },
|
|
629
|
-
"selection": "id,total,status"
|
|
630
|
-
}
|
|
631
|
-
},
|
|
632
|
-
"joinGroup": {
|
|
633
|
-
"orders": {
|
|
634
|
-
"aggregations": {
|
|
635
|
-
"by": "status",
|
|
636
|
-
"sum": ["total"],
|
|
637
|
-
"min": ["total"],
|
|
638
|
-
"max": ["total"],
|
|
639
|
-
"avg": ["total"],
|
|
640
|
-
"count": ["id"],
|
|
641
|
-
"distinctCount": ["status"]
|
|
642
|
-
},
|
|
643
|
-
"filters": { "status": "completed" }
|
|
644
|
-
}
|
|
645
|
-
},
|
|
646
|
-
"joinLeft": {
|
|
647
|
-
"creator": { "selection": "id,name,email" }
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
```
|
|
651
|
-
|
|
652
|
-
- **`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.
|
|
653
|
-
- **`conditions`** — array of `{ field, method, params }`. Methods: `isEqual`, `isNotEqual`, `isGreater`, `isGreaterOrEqual`, `isLess`, `isLessOrEqual`, `isLike`, `isILike`, `isIn`, `isNotIn`, `isBetween`, `isNotBetween`, `isNull`, `isNotNull`.
|
|
654
|
-
- **`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`.
|
|
655
|
-
- **`joinMultiple`** — fetches related child rows in a side query. Accepts `{ filters, conditions, selection }`. Same `unique: false` aliases.
|
|
656
|
-
- **`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.
|
|
657
|
-
- **`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.
|
|
658
|
-
|
|
659
|
-
**Dot-notation in `orderBy` and `conditions`**:
|
|
660
|
-
|
|
661
|
-
| Form | Source | Example |
|
|
662
|
-
|------|--------|---------|
|
|
663
|
-
| `<field>` | main schema | `orderBy=name ASC` |
|
|
664
|
-
| `<alias>.<field>` | `joinLeft` aliases (`unique: true`) | `orderBy=creator.name ASC` |
|
|
665
|
-
| `<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] }]` |
|
|
666
|
-
|
|
667
|
-
**Querystring** (optional): `orderBy`, `page`, `itemsPerPage`, `computeMin`, `computeMax`, `computeSum`, `computeAvg`
|
|
668
|
-
|
|
669
|
-
> `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).
|
|
670
|
-
|
|
671
|
-
**Response:**
|
|
672
|
-
|
|
673
|
-
```json
|
|
674
|
-
{
|
|
675
|
-
"table": "customer",
|
|
676
|
-
"main": [{ "id": 1, "name": "Mario", "email": "m@test.it" }],
|
|
677
|
-
"joinLeft": { "creator": [{ "id": 7, "name": "Alice", "email": "a@x.it" }] },
|
|
678
|
-
"joinMultiple": { "orders": [{ "id": 10, "customerId": 1, "total": 50 }] },
|
|
679
|
-
"joinGroup": {
|
|
680
|
-
"orders": {
|
|
681
|
-
"sum": { "total": 300 },
|
|
682
|
-
"count": { "id": 2 },
|
|
683
|
-
"rows": [{ "by": "completed", "sum_total": 300, "count_id": 2 }]
|
|
684
|
-
}
|
|
685
|
-
},
|
|
686
|
-
"pagination": {
|
|
687
|
-
"total": 25,
|
|
688
|
-
"pages": 3,
|
|
689
|
-
"computed": { "min": { "id": 1 }, "max": { "id": 100 } },
|
|
690
|
-
"paginator": { "page": 1, "itemsPerPage": 20 }
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
```
|
|
694
|
-
|
|
695
|
-
`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.
|
|
696
|
-
|
|
697
|
-
### GET /rest/{table}/:id
|
|
698
|
-
|
|
699
|
-
Returns `{ main: { ... } }` or 404.
|
|
700
|
-
|
|
701
|
-
> **Composite primary keys:** `GET /rest/:id`, `DELETE /rest/:id` and `POST /bulk/:table/delete`
|
|
702
|
-
> address a record by a single PK value, so they are **not registered** for tables whose
|
|
703
|
-
> primary key spans multiple columns (a match on the first column alone could hit many rows —
|
|
704
|
-
> for the deletes, destructively). Explicitly listing one of these operations in `operations`
|
|
705
|
-
> for such a table throws at startup. Use `search` with all PK fields in the filters, `update`
|
|
706
|
-
> (which matches every PK column), or a custom route.
|
|
707
|
-
|
|
708
|
-
### POST /rest/{table}
|
|
709
|
-
|
|
710
|
-
**Body:**
|
|
711
|
-
|
|
712
|
-
```json
|
|
713
|
-
{
|
|
714
|
-
"main": { "name": "Mario", "email": "m@test.it" },
|
|
715
|
-
"secondaries": {
|
|
716
|
-
"orders": [{ "total": 50, "status": "pending" }]
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
```
|
|
720
|
-
|
|
721
|
-
`secondaries` keys are the **alias** declared in `allowedWriteJoins`. FK fields are auto-filled from the inserted main record.
|
|
722
|
-
|
|
723
|
-
> **Owned child tables (translations, `*_info` details): use a writeJoin, not a standalone table.**
|
|
724
|
-
> 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:
|
|
725
|
-
> ```typescript
|
|
726
|
-
> // on the parent (product) table:
|
|
727
|
-
> allowedWriteJoins: [
|
|
728
|
-
> buildRelation(SchemaProduct, 'id', SchemaProductInfo, 'productId', { alias: 'translations' }),
|
|
729
|
-
> ],
|
|
730
|
-
> upsertMap: buildUpsertRules(
|
|
731
|
-
> buildUpsertRule(SchemaProductInfo, ['productId', 'lang']), // composite conflict key
|
|
732
|
-
> ),
|
|
733
|
-
> // → PUT /rest/product { "main": {...}, "secondaries": { "translations": [{ "lang": "en", "name": "Bike" }] } }
|
|
734
|
-
> ```
|
|
735
|
-
> 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).
|
|
736
|
-
|
|
737
|
-
**Response (201):** `{ main: { ... }, secondaries: { ... } }`
|
|
738
|
-
|
|
739
|
-
### PUT /rest/{table}
|
|
740
|
-
|
|
741
|
-
**Body:**
|
|
742
|
-
|
|
743
|
-
```json
|
|
744
|
-
{
|
|
745
|
-
"main": { "id": 1, "name": "Updated Name" },
|
|
746
|
-
"secondaries": { "orders": [{ "total": 75 }] },
|
|
747
|
-
"deletions": { "orders": [{ "id": 10 }] }
|
|
748
|
-
}
|
|
749
|
-
```
|
|
750
|
-
|
|
751
|
-
`main` must include the primary key. `secondaries` and `deletions` are optional.
|
|
752
|
-
|
|
753
|
-
### DELETE /rest/{table}/:id
|
|
754
|
-
|
|
755
|
-
**Response (200):** `{ main: { <pk>: ... } }` (PK-only, like all write responses), or `404` if
|
|
756
|
-
the record does not exist.
|
|
757
|
-
|
|
758
|
-
### PUT /bulk/{table}
|
|
759
|
-
|
|
760
|
-
**Body:** Array of `{ main, secondaries?, deletions? }`. All main records are inserted/upserted in a single SQL query.
|
|
761
|
-
|
|
762
|
-
### POST /bulk/{table}/delete
|
|
763
|
-
|
|
764
|
-
**Body:** Array of objects with the PK field, e.g. `[{ "id": 1 }, { "id": 2 }]`. Executes as a single `DELETE WHERE pk IN (...)`.
|
|
765
|
-
|
|
766
|
-
### Validation errors (400)
|
|
767
|
-
|
|
768
|
-
Both schema-level (TypeBox/Ajv) and custom (`validate` / `validateBulk`) errors use the same response shape:
|
|
769
|
-
|
|
770
|
-
```json
|
|
771
|
-
{
|
|
772
|
-
"statusCode": 400,
|
|
773
|
-
"error": "Bad Request",
|
|
774
|
-
"message": "Validation failed",
|
|
775
|
-
"fields": [
|
|
776
|
-
{ "path": "main.email", "code": "format", "message": "must match format \"email\"" },
|
|
777
|
-
{ "path": "name", "code": "required", "message": "is required" }
|
|
778
|
-
]
|
|
779
|
-
}
|
|
780
|
-
```
|
|
781
|
-
|
|
782
|
-
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.
|
|
783
|
-
|
|
784
|
-
### Server errors (500)
|
|
785
|
-
|
|
786
|
-
Anything the plugin did not raise itself — a constraint violation, a hook failing — answers
|
|
787
|
-
with a fixed body carrying no database detail; `requestId` points at the log line with the
|
|
788
|
-
real error. Constraint names and driver messages stay on the server unless the deployment
|
|
789
|
-
opts in with `exposeDebugInfo: true`: see
|
|
790
|
-
[Error responses don't describe your schema](#error-responses-dont-describe-your-schema).
|
|
791
|
-
|
|
792
|
-
```json
|
|
793
|
-
{ "statusCode": 500, "error": "Internal Server Error", "message": "Internal Server Error", "requestId": "req-3" }
|
|
794
|
-
```
|
|
795
|
-
|
|
796
|
-
## Multi-Tenant
|
|
797
|
-
|
|
798
|
-
Automatic row-level isolation on all CRUD operations. Configure once, no code in handlers.
|
|
799
|
-
|
|
800
|
-
```typescript
|
|
801
|
-
await app.register(fastifyAutoSqlApi, {
|
|
802
|
-
DbTables: dbTables,
|
|
803
|
-
getTenantId: (request) => request.user?.organizationId ?? null,
|
|
804
|
-
});
|
|
805
|
-
```
|
|
806
|
-
|
|
807
|
-
When `getTenantId` returns `null`, no filtering is applied (admin mode).
|
|
808
|
-
|
|
809
|
-
The tenant belongs to the **request**, not to the table it addresses: a scoped table is filtered
|
|
810
|
-
even when it is reached as a join target, or written as a secondary, of a host table that
|
|
811
|
-
declares no scope of its own — a relation is a read grant, and `tenantScope` is the cap on it. So
|
|
812
|
-
`getTenantId` must tolerate a request with no authenticated user; return `null` there. It is not
|
|
813
|
-
called at all when neither the addressed table nor anything it relates to is scoped.
|
|
814
|
-
|
|
815
|
-
### Direct tenant (column on the table)
|
|
816
|
-
|
|
817
|
-
```typescript
|
|
818
|
-
defineTable({
|
|
819
|
-
primary: 'id',
|
|
820
|
-
...exportTableInfo(SchemaCustomer),
|
|
821
|
-
tenantScope: { column: 'organization_id' },
|
|
822
|
-
});
|
|
823
|
-
```
|
|
824
|
-
|
|
825
|
-
### Indirect tenant (via parent table)
|
|
826
|
-
|
|
827
|
-
```typescript
|
|
828
|
-
defineTable({
|
|
829
|
-
primary: 'id',
|
|
830
|
-
...exportTableInfo(SchemaOrder),
|
|
831
|
-
tenantScope: {
|
|
832
|
-
column: 'organization_id',
|
|
833
|
-
through: { schema: SchemaCustomer, localField: 'customer_id', foreignField: 'id' },
|
|
834
|
-
},
|
|
835
|
-
});
|
|
836
|
-
```
|
|
837
|
-
|
|
838
|
-
### Shared tenant (several owner columns)
|
|
839
|
-
|
|
840
|
-
Some rows belong to two parties and either may see them — a message (`sender_id` /
|
|
841
|
-
`recipient_id`), a transfer (`from_account_id` / `to_account_id`), a shift swap. `anyOf`
|
|
842
|
-
declares that shape:
|
|
843
|
-
|
|
844
|
-
```typescript
|
|
845
|
-
defineTable({
|
|
846
|
-
primary: 'id',
|
|
847
|
-
...exportTableInfo(SchemaShiftSwapRequest),
|
|
848
|
-
tenantScope: { anyOf: ['requester_agent_id', 'target_agent_id'] },
|
|
849
|
-
});
|
|
850
|
-
```
|
|
851
|
-
|
|
852
|
-
Reads add `AND (requester_agent_id IN (…) OR target_agent_id IN (…))`. A `NULL` column simply
|
|
853
|
-
does not match; the other party still decides. Writes cannot auto-inject an owner — the plugin
|
|
854
|
-
cannot know which party the caller is — so the payload must **anchor** the row: at least one
|
|
855
|
-
listed column present and holding a tenant id, otherwise `400` (nothing to anchor to) or `403`
|
|
856
|
-
(present but foreign). The other party is left exactly as sent, which is the point: a swap
|
|
857
|
-
request names a colleague by definition. Updates strip every listed column from the `SET` and
|
|
858
|
-
upserts leave them out of the `DO UPDATE` — neither party can be re-assigned.
|
|
859
|
-
|
|
860
|
-
The three forms do not mix: `defineTable` throws at startup on `anyOf` combined with `column`
|
|
861
|
-
or `through`.
|
|
862
|
-
|
|
863
|
-
Tables without `tenantScope` are unaffected.
|
|
864
|
-
|
|
865
|
-
### Isolation guarantees on writes
|
|
866
|
-
|
|
867
|
-
Tenant isolation is enforced on write paths, not just reads:
|
|
868
|
-
|
|
869
|
-
- **Upsert conflicts are ownership-checked.** On a tenant-scoped table with an `upsertMap`, an
|
|
870
|
-
upsert (`POST /rest/:table` or `PUT /bulk/:table`) whose conflict key matches a row owned by
|
|
871
|
-
another tenant is rejected with `403` — it cannot overwrite or re-assign that row.
|
|
872
|
-
- **The tenant link cannot be changed to another tenant.** For direct scopes the tenant column is
|
|
873
|
-
stripped from update payloads. For indirect scopes, changing the through-FK (`localField`) is
|
|
874
|
-
allowed only to a value the caller owns; otherwise the update returns `403`.
|
|
875
|
-
|
|
876
|
-
## Swagger
|
|
877
|
-
|
|
878
|
-
Enabled by passing `swagger: true` (or a config object) to the plugin options. Requires `@fastify/swagger` and `@fastify/swagger-ui` as peer dependencies.
|
|
879
|
-
|
|
880
|
-
```bash
|
|
881
|
-
npm install @fastify/swagger @fastify/swagger-ui
|
|
882
|
-
```
|
|
883
|
-
|
|
884
|
-
```typescript
|
|
885
|
-
await app.register(fastifyAutoSqlApi, {
|
|
886
|
-
DbTables: dbTables,
|
|
887
|
-
swagger: {
|
|
888
|
-
title: 'My API',
|
|
889
|
-
description: 'Auto-generated CRUD API',
|
|
890
|
-
version: '1.0.0',
|
|
891
|
-
routePrefix: '/docs',
|
|
892
|
-
},
|
|
893
|
-
});
|
|
894
|
-
```
|
|
895
|
-
|
|
896
|
-
If the swagger packages are not installed, the plugin logs a warning and continues without Swagger.
|
|
897
|
-
|
|
898
|
-
## Granular Composition
|
|
899
|
-
|
|
900
|
-
Instead of registering the all-in-one plugin, you can register individual route plugins for more control:
|
|
901
|
-
|
|
902
|
-
```typescript
|
|
903
|
-
import {
|
|
904
|
-
searchRoutes, getRoutes, insertRoutes, updateRoutes,
|
|
905
|
-
deleteRoutes, bulkUpsertRoutes, bulkDeleteRoutes, setupSwagger,
|
|
906
|
-
} from 'fastify-auto-sqlapi';
|
|
907
|
-
|
|
908
|
-
// Read-only API
|
|
909
|
-
await app.register(async (instance) => {
|
|
910
|
-
await setupSwagger(instance, { swagger: true });
|
|
911
|
-
const opts = { DbTables: dbTables };
|
|
912
|
-
await instance.register(searchRoutes, opts);
|
|
913
|
-
await instance.register(getRoutes, opts);
|
|
914
|
-
}, { prefix: '/public' });
|
|
915
|
-
|
|
916
|
-
// Full CRUD with auth
|
|
917
|
-
await app.register(async (instance) => {
|
|
918
|
-
const opts = { DbTables: dbTables, onRequests: [authMiddleware] };
|
|
919
|
-
await instance.register(searchRoutes, opts);
|
|
920
|
-
await instance.register(getRoutes, opts);
|
|
921
|
-
await instance.register(insertRoutes, opts);
|
|
922
|
-
await instance.register(updateRoutes, opts);
|
|
923
|
-
await instance.register(deleteRoutes, opts);
|
|
924
|
-
await instance.register(bulkUpsertRoutes, opts);
|
|
925
|
-
await instance.register(bulkDeleteRoutes, opts);
|
|
926
|
-
}, { prefix: '/admin' });
|
|
927
|
-
```
|
|
928
|
-
|
|
929
|
-
## LLM / agent clients
|
|
930
|
-
|
|
931
|
-
The plugin is a natural enforcement layer for an agent that operates on your data through
|
|
932
|
-
chat: whatever requests the LLM invents, they pass through the same tenant scoping,
|
|
933
|
-
`readExclude`, `operations` whitelist, validation and request caps as any HTTP client —
|
|
934
|
-
direct-DB firepower, backoffice constraints. Three pieces make this practical:
|
|
935
|
-
|
|
936
|
-
**1. The grammar** — [`AGENTS_FRONTEND.md`](./AGENTS_FRONTEND.md) (ships in the npm package)
|
|
937
|
-
is a compact, LLM-oriented reference of how to call the API: search with all four join
|
|
938
|
-
families, ordering, pagination, writes, error shapes. Put it in the system prompt.
|
|
939
|
-
|
|
940
|
-
**2. The vocabulary** — enable the manifest endpoint to describe *this deployment's* tables:
|
|
941
|
-
|
|
942
|
-
```typescript
|
|
943
|
-
await app.register(fastifyAutoSqlApi, {
|
|
944
|
-
DbTables: dbTables,
|
|
945
|
-
agentManifest: true, // GET {prefix}/agent/manifest (JSON) + /agent/manifest.md
|
|
946
|
-
});
|
|
947
|
-
```
|
|
948
|
-
|
|
949
|
-
`GET /agent/manifest.md` returns a compact markdown block per table — fields with
|
|
950
|
-
type/required/nullable, enabled operations, join aliases (with direction), computed fields,
|
|
951
|
-
extra filters — always in sync with the running config, behind the same `onRequests` auth.
|
|
952
|
-
Fetch it at session start: grammar + vocabulary is everything the model needs. The same data
|
|
953
|
-
is available programmatically via `buildAgentManifest(dbTables)` /
|
|
954
|
-
`renderAgentManifestMd(manifest)`.
|
|
955
|
-
|
|
956
|
-
**3. The guardrails** — writes are often too dangerous to hand to a model. Register a
|
|
957
|
-
read-only surface with granular composition and give the agent only that:
|
|
958
|
-
|
|
959
|
-
```typescript
|
|
960
|
-
import { searchRoutes, getRoutes, agentManifestRoutes } from 'fastify-auto-sqlapi';
|
|
961
|
-
|
|
962
|
-
await app.register(async (instance) => {
|
|
963
|
-
const opts = { DbTables: dbTables, onRequests: [agentAuth] };
|
|
964
|
-
await instance.register(searchRoutes, opts);
|
|
965
|
-
await instance.register(getRoutes, opts);
|
|
966
|
-
await instance.register(agentManifestRoutes, opts);
|
|
967
|
-
}, { prefix: '/agent' }); // reads only: no insert/update/delete routes exist here
|
|
968
|
-
```
|
|
969
|
-
|
|
970
|
-
**Validation strategy** — two options, per table or per deployment:
|
|
971
|
-
|
|
972
|
-
- *Loose tool + retry loop* (recommended default): expose a generic tool
|
|
973
|
-
(`table` as enum from the manifest, `body` as free object) and let the plugin validate.
|
|
974
|
-
A failed request returns a structured 400 with `fields: [{path, code, message}]` — feed
|
|
975
|
-
it back to the model and it self-corrects in one round trip. Cheap prompts, no schema
|
|
976
|
-
duplication.
|
|
977
|
-
- *Strict tools*: `agentToolSchemas(dbTables, 'customer')` returns the exact JSON Schemas
|
|
978
|
-
the routes validate with (body + querystring for search, bodies for writes, only for the
|
|
979
|
-
operations enabled on that table) — plug them into provider-side tool definitions when
|
|
980
|
-
you want invalid calls rejected before they leave the model. Costs prompt size; best for
|
|
981
|
-
a few hot tables.
|
|
982
|
-
|
|
983
|
-
## Design Decisions
|
|
984
|
-
|
|
985
|
-
Deliberate, non-obvious choices — open-by-default, non-transactional bulk operations,
|
|
986
|
-
always-updatable fields, raw DB errors, insert-pipeline ordering — are recorded as
|
|
987
|
-
[Architecture Decision Records in `docs/adr/`](./docs/adr/README.md), each with its
|
|
988
|
-
rationale and the alternatives that were rejected. Read them before filing an issue that
|
|
989
|
-
proposes changing one of these behaviors.
|
|
990
|
-
|
|
991
|
-
## Conventions
|
|
992
|
-
|
|
993
|
-
- **camelCase everywhere in the API** — requests, responses, `validate`, and all hooks (`beforeInsert`/`afterInsert`, `beforeUpdate`/`afterUpdate`, `beforeDelete`/`afterDelete`, `beforeBulkDelete`/`afterBulkDelete`) use schema field names
|
|
994
|
-
- **Conversion to DB column format is automatic** via `colMap` — supports both snake_case and camelCase DB columns (e.g. betterauth-style)
|
|
995
|
-
- **Aliases identify joins** — declared in `buildRelation({ alias })`, used as keys in request/response/`secondaries`/dotted notation
|
|
996
|
-
- **`joinMustExist` / `joinMultiple` / `joinGroup`** are 1:N (child→main) and use side queries / EXISTS / correlated subqueries — no row duplication
|
|
997
|
-
- **`joinLeft`** is N:1 (parent→main) and adds a real `LEFT JOIN` on demand (only when filtering/ordering by parent)
|
|
998
|
-
- **All response fields are Optional** — response schemas use `Type.Partial` since `RETURNING *` may return any subset
|
|
999
|
-
- **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`
|
|
1000
|
-
- **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
|
|
1001
|
-
|
|
1002
|
-
## Re-exports
|
|
1003
|
-
|
|
1004
|
-
The package re-exports commonly needed utilities so you don't need to install them separately:
|
|
1005
|
-
|
|
1006
|
-
```typescript
|
|
1007
|
-
import {
|
|
1008
|
-
Type, // from @sinclair/typebox
|
|
1009
|
-
type Static, // from @sinclair/typebox
|
|
1010
|
-
ConditionBuilder, // from node-condition-builder
|
|
1011
|
-
Expression, // from node-condition-builder
|
|
1012
|
-
|
|
1013
|
-
// Table configuration
|
|
1014
|
-
defineTable,
|
|
1015
|
-
exportTableInfo,
|
|
1016
|
-
buildRelation,
|
|
1017
|
-
buildUpsertRule,
|
|
1018
|
-
buildUpsertRules,
|
|
1019
|
-
Nullable, // type-array nullable schema (see Conventions)
|
|
1020
|
-
|
|
1021
|
-
// DB layer
|
|
1022
|
-
QueryClient, // raw SQL query helper
|
|
1023
|
-
createQueryClient, // factory with dialect string
|
|
1024
|
-
pgQueryable, // pg pool adapter
|
|
1025
|
-
mysqlQueryable, // mysql2 pool adapter
|
|
1026
|
-
|
|
1027
|
-
// Programmatic high-level API
|
|
1028
|
-
createSqlApi, // standalone SqlApi (for scripts/tests)
|
|
1029
|
-
setupSwagger, // manual Swagger registration
|
|
1030
|
-
} from 'fastify-auto-sqlapi';
|
|
1031
|
-
|
|
1032
|
-
import type {
|
|
1033
|
-
ValidationError, // [field, code] | [field, code, message]
|
|
1034
|
-
ValidatorFn,
|
|
1035
|
-
BulkValidatorFn,
|
|
1036
|
-
JoinDefinition,
|
|
1037
|
-
JoinRefFilter,
|
|
1038
|
-
JoinFetchRequest,
|
|
1039
|
-
JoinGroupRequest,
|
|
1040
|
-
SearchCondition,
|
|
1041
|
-
ConditionMethod,
|
|
1042
|
-
} from 'fastify-auto-sqlapi';
|
|
1043
|
-
```
|
|
1044
|
-
|
|
1045
|
-
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.
|
|
320
|
+
Three properties make the generated configuration reviewable, by a person or by an agent: a
|
|
321
|
+
table is **one local `defineTable()` call** with nothing to wire across files; there is **one
|
|
322
|
+
naming convention** (camelCase in requests, responses, hooks and validators — the mapping to DB
|
|
323
|
+
columns is automatic); and a `400` carries `fields: [{ path, code, message }]`, so a failing
|
|
324
|
+
request says exactly what to fix.
|
|
1046
325
|
|
|
1047
326
|
## Requirements
|
|
1048
327
|
|
|
1049
|
-
- Node.js >= 18
|
|
1050
|
-
-
|
|
1051
|
-
- One of:
|
|
1052
|
-
- **PostgreSQL** + `pg` + `@fastify/postgres`
|
|
1053
|
-
- **MySQL** / **MariaDB** + `mysql2`
|
|
328
|
+
- Node.js >= 18, Fastify >= 5 (peer dependency)
|
|
329
|
+
- **PostgreSQL** + `pg` + `@fastify/postgres`, or **MySQL** / **MariaDB** + `mysql2`
|
|
1054
330
|
- Optional: `@fastify/swagger` + `@fastify/swagger-ui` for Swagger UI
|
|
1055
331
|
|
|
1056
332
|
## License
|
|
1057
333
|
|
|
1058
334
|
MIT
|
|
335
|
+
|