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.
Files changed (122) hide show
  1. package/AGENTS.md +50 -0
  2. package/AGENTS_BACKEND.md +312 -9
  3. package/AGENTS_FRONTEND.md +1 -1
  4. package/BREAKING_CHANGES.md +132 -0
  5. package/CHANGELOG.md +170 -0
  6. package/README.md +255 -877
  7. package/dist/bin/generate-schema.js +49 -2
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/lib/agent/manifest.d.ts +2 -0
  12. package/dist/lib/agent/manifest.d.ts.map +1 -1
  13. package/dist/lib/agent/manifest.js +4 -0
  14. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  15. package/dist/lib/cli/mysql-introspect.js +15 -5
  16. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  17. package/dist/lib/cli/pg-introspect.js +53 -14
  18. package/dist/lib/cli/schema-codegen.d.ts +2 -1
  19. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  20. package/dist/lib/cli/schema-codegen.js +17 -4
  21. package/dist/lib/cli/tables-codegen.d.ts +4 -0
  22. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  23. package/dist/lib/cli/tables-codegen.js +125 -27
  24. package/dist/lib/db.d.ts +6 -1
  25. package/dist/lib/db.d.ts.map +1 -1
  26. package/dist/lib/db.js +8 -2
  27. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  28. package/dist/lib/engine/bulk/bulk-upsert.js +6 -5
  29. package/dist/lib/engine/read-hooks.d.ts +19 -0
  30. package/dist/lib/engine/read-hooks.d.ts.map +1 -0
  31. package/dist/lib/engine/read-hooks.js +20 -0
  32. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  33. package/dist/lib/engine/rest/get.js +10 -6
  34. package/dist/lib/engine/rest/insert.js +1 -1
  35. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  36. package/dist/lib/engine/rest/update.js +15 -11
  37. package/dist/lib/engine/search/join-families.d.ts +11 -2
  38. package/dist/lib/engine/search/join-families.d.ts.map +1 -1
  39. package/dist/lib/engine/search/join-families.js +11 -13
  40. package/dist/lib/engine/search/search.d.ts.map +1 -1
  41. package/dist/lib/engine/search/search.js +14 -7
  42. package/dist/lib/engine/validate.d.ts +3 -3
  43. package/dist/lib/engine/validate.d.ts.map +1 -1
  44. package/dist/lib/engine/write-helpers.d.ts +7 -3
  45. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  46. package/dist/lib/engine/write-helpers.js +29 -30
  47. package/dist/lib/errors.d.ts +24 -0
  48. package/dist/lib/errors.d.ts.map +1 -1
  49. package/dist/lib/errors.js +59 -0
  50. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  51. package/dist/lib/schema/bulk-upsert.js +2 -1
  52. package/dist/lib/schema/get.d.ts +14 -0
  53. package/dist/lib/schema/get.d.ts.map +1 -0
  54. package/dist/lib/schema/get.js +18 -0
  55. package/dist/lib/schema/helpers.d.ts +21 -3
  56. package/dist/lib/schema/helpers.d.ts.map +1 -1
  57. package/dist/lib/schema/helpers.js +37 -5
  58. package/dist/lib/schema/insert.d.ts.map +1 -1
  59. package/dist/lib/schema/insert.js +7 -3
  60. package/dist/lib/schema/search.d.ts.map +1 -1
  61. package/dist/lib/schema/search.js +3 -11
  62. package/dist/lib/schema/update.d.ts.map +1 -1
  63. package/dist/lib/schema/update.js +7 -3
  64. package/dist/lib/sql-api.d.ts +9 -9
  65. package/dist/lib/sql-api.d.ts.map +1 -1
  66. package/dist/lib/sql-api.js +2 -1
  67. package/dist/lib/table-helpers.d.ts.map +1 -1
  68. package/dist/lib/table-helpers.js +169 -0
  69. package/dist/lib/tenant.d.ts +43 -8
  70. package/dist/lib/tenant.d.ts.map +1 -1
  71. package/dist/lib/tenant.js +169 -54
  72. package/dist/lib/write-access.d.ts +36 -0
  73. package/dist/lib/write-access.d.ts.map +1 -0
  74. package/dist/lib/write-access.js +58 -0
  75. package/dist/routes/auto/get.routes.d.ts.map +1 -1
  76. package/dist/routes/auto/get.routes.js +3 -2
  77. package/dist/routes/auto/plugin.d.ts.map +1 -1
  78. package/dist/routes/auto/plugin.js +8 -2
  79. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  80. package/dist/routes/auto/route-helpers.js +13 -4
  81. package/dist/routes/setup-swagger.d.ts.map +1 -0
  82. package/dist/{lib → routes}/sql-api-decorator.d.ts +1 -1
  83. package/dist/routes/sql-api-decorator.d.ts.map +1 -0
  84. package/dist/{lib → routes}/sql-api-decorator.js +4 -4
  85. package/dist/types/cli.d.ts +27 -1
  86. package/dist/types/cli.d.ts.map +1 -1
  87. package/dist/types/dml.d.ts +6 -4
  88. package/dist/types/dml.d.ts.map +1 -1
  89. package/dist/types/plugin.d.ts +22 -3
  90. package/dist/types/plugin.d.ts.map +1 -1
  91. package/dist/types/request.d.ts +24 -0
  92. package/dist/types/request.d.ts.map +1 -0
  93. package/dist/types/request.js +1 -0
  94. package/dist/types/schema.d.ts +15 -0
  95. package/dist/types/schema.d.ts.map +1 -1
  96. package/dist/types/search.d.ts +3 -0
  97. package/dist/types/search.d.ts.map +1 -1
  98. package/dist/types/table.d.ts +84 -11
  99. package/dist/types/table.d.ts.map +1 -1
  100. package/dist/types/tenant.d.ts +26 -2
  101. package/dist/types/tenant.d.ts.map +1 -1
  102. package/dist/types/validation.d.ts +3 -3
  103. package/dist/types/validation.d.ts.map +1 -1
  104. package/dist/types.d.ts +1 -0
  105. package/dist/types.d.ts.map +1 -1
  106. package/dist/types.js +1 -0
  107. package/docs/GLOSSARY.md +3 -1
  108. package/docs/adr/0004-updates-always-open.md +2 -1
  109. package/docs/adr/0006-raw-db-errors.md +2 -1
  110. package/docs/adr/0010-joins-do-not-run-route-guards.md +4 -1
  111. package/docs/adr/0013-sanitized-db-errors.md +114 -0
  112. package/docs/adr/0014-what-follows-a-write-join.md +84 -0
  113. package/docs/adr/0015-non-writable-columns.md +86 -0
  114. package/docs/adr/0016-a-view-is-a-table-config.md +92 -0
  115. package/docs/adr/0017-the-schema-is-the-contract.md +85 -0
  116. package/docs/adr/0018-one-seam-to-the-http-framework.md +68 -0
  117. package/docs/adr/README.md +6 -0
  118. package/package.json +1 -1
  119. package/dist/lib/setup-swagger.d.ts.map +0 -1
  120. package/dist/lib/sql-api-decorator.d.ts.map +0 -1
  121. /package/dist/{lib → routes}/setup-swagger.d.ts +0 -0
  122. /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 complete REST API inside your [Fastify](https://fastify.dev/) app.**
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
- Two CLI commands introspect PostgreSQL / MySQL / MariaDB and generate typed schemas; one `register()` call turns each table *you choose to expose* into seven endpoints: search with filters, advanced conditions, relation joins and aggregations; validated writes with nested child records; bulk upsert/delete; multi-tenant isolation; Swagger docs. Related tables don't need endpoints of their own — reads reach them through joins, writes through nested children — so a whole subgraph can live behind a single exposed table. What's left to write is what makes your product yours — auth, business rules, custom logic — as plain TypeScript hooks next to your other routes.
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
- One request, to show the point — *"active customers with over 500 total in completed orders, with their order stats, biggest spenders first"*:
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.organizationId`), 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:
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
- ## 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.
75
-
76
-
77
- ## Quick Start
78
-
79
- ### 1. Install
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
- ### 3. Generate schemas from your database
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 # all tables
127
- npx sqlapi-generate-schema --tables customer,order # only some
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
- This introspects your tables and generates one TypeBox schema file per table in
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
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(fastifyPostgres, {
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
- That's it. For a table called `customer`, you now have:
176
-
177
- | Method | URL | Description |
178
- |--------|-----|-------------|
179
- | `POST` | `/api/search/customer` | Search with filters, pagination, joins |
180
- | `GET` | `/api/rest/customer/:id` | Get a single record by primary key |
181
- | `POST` | `/api/rest/customer` | Insert a new record |
182
- | `PUT` | `/api/rest/customer` | Update a record |
183
- | `DELETE` | `/api/rest/customer/:id` | Delete a record |
184
- | `PUT` | `/api/bulk/customer` | Bulk upsert (array of records) |
185
- | `POST` | `/api/bulk/customer/delete` | Bulk delete (array of PKs) |
186
-
187
- > **Note:** Search uses `POST` because filters are passed as JSON in the request body.
188
-
189
- ## Working with coding agents
190
-
191
- The library is documented for two readers. Humans get this README; agents get task-oriented
192
- files that ship in the published package, so they are already on disk after `npm install`:
193
-
194
- | File | Contents |
195
- | -------------------------------------- | ----------------------------------------------------------------- |
196
- | `AGENTS.md` | Entry point: what the library does, which file to read for what |
197
- | `AGENTS_BACKEND.md` | `defineTable` reference, hooks, tenancy, computed fields, CLI |
198
- | `AGENTS_FRONTEND.md` | Request/response shapes for every endpoint |
199
- | `docs/adr/` | Why the non-obvious behaviours are what they are |
200
-
201
- Point your agent at them once:
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 design choices make the generated code predictable enough for an agent to write it
206
- unsupervised:
207
-
208
- - **Configuration is declarative and local.** A table is one `defineTable()` call — nothing to
209
- wire across files, so a diff is reviewable at a glance.
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
- - Fastify >= 5 (peer dependency)
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