fastify-auto-sqlapi 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +50 -0
- package/AGENTS_BACKEND.md +264 -6
- package/AGENTS_FRONTEND.md +1 -1
- package/BREAKING_CHANGES.md +51 -0
- package/CHANGELOG.md +113 -0
- package/README.md +254 -983
- package/dist/bin/generate-schema.js +49 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/agent/manifest.d.ts +2 -0
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +4 -0
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +15 -5
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +53 -14
- package/dist/lib/cli/schema-codegen.d.ts +2 -1
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +17 -4
- package/dist/lib/cli/tables-codegen.d.ts +4 -0
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +125 -27
- package/dist/lib/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/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 +3 -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/AGENTS.md
CHANGED
|
@@ -6,6 +6,56 @@ You are configuring a Fastify server that uses `fastify-auto-sqlapi` to auto-gen
|
|
|
6
6
|
|
|
7
7
|
The plugin generates REST endpoints (search, get, insert, update, delete, bulk upsert, bulk delete) from database table definitions. No ORM — raw SQL. Supports **PostgreSQL**, **MySQL**, and **MariaDB**. The consumer defines table configurations, and the plugin handles routing, validation, and query execution.
|
|
8
8
|
|
|
9
|
+
**The database schema is the API contract, on purpose.** The exposed shape follows the stored shape *by default*, and every part of that default is overridable — `readExclude`, `writeExclude`, a trimmed Schema, `schemaOverrides`, `computedFields`, `afterRead`, `operations`, relation `alias`/`fields`, `extraFilters`. Two consequences for how you work:
|
|
10
|
+
|
|
11
|
+
- Do not propose a DTO or mapping layer, and do not reshape a response by hand in a route wrapper. Reach for the lever that matches the concern (the table is in [ADR 0017](./docs/adr/0017-the-schema-is-the-contract.md)); when none fits, the operation is not CRUD — write a custom route and call `app.sqlApi.*` inside it, which keeps filters, joins, tenant scoping, hooks and validation.
|
|
12
|
+
- The target surface is internal: back-office and admin tools where the schema *is* the domain model. If the user is building a **public or third-party API** — consumers they cannot redeploy with their schema, a versioned contract — say so, and keep those tables off the auto routes via `operations` rather than generating the surface from the schema.
|
|
13
|
+
|
|
14
|
+
## Start here
|
|
15
|
+
|
|
16
|
+
These files are the **contract**. Everything a consumer can rely on is documented here; find
|
|
17
|
+
the answer in the table below and stop there.
|
|
18
|
+
|
|
19
|
+
| I need to… | Read |
|
|
20
|
+
|---|---|
|
|
21
|
+
| Call the API from a client (fetch, list page, form, agent tool) | **[AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md)** — whole file, it is the complete request grammar |
|
|
22
|
+
| Know *this* deployment's tables, fields and join aliases | `GET {prefix}/agent/manifest.md` (when `agentManifest` is on), or Swagger — the docs give the grammar, the manifest gives the vocabulary |
|
|
23
|
+
| Add or configure a table, joins, tenant scope, hooks, validation | **[AGENTS_BACKEND.md](./AGENTS_BACKEND.md)** — see its section map |
|
|
24
|
+
| Query the data from a custom route, a script or a background job | AGENTS_BACKEND.md → *SqlApi* |
|
|
25
|
+
| Run raw SQL inside a hook | AGENTS_BACKEND.md → *QueryClient API* |
|
|
26
|
+
| Migrate from the old join API (`joinFilters`, `joins`, `joinGroups`) | **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)** |
|
|
27
|
+
| Understand why a behavior that looks wrong is intended | **[docs/adr/](./docs/adr/README.md)** |
|
|
28
|
+
|
|
29
|
+
**Do not read the plugin's own sources.** `node_modules/fastify-auto-sqlapi/dist/` is compiled
|
|
30
|
+
output: internal helpers, no stability guarantee, and no indication of which behaviors are
|
|
31
|
+
deliberate. An answer reverse-engineered from it is right today and wrong at the next release.
|
|
32
|
+
If a question is not answered by the files above, say so and ask — do not infer the API from
|
|
33
|
+
the implementation, and never copy an internal helper into consumer code.
|
|
34
|
+
|
|
35
|
+
## Naming: camelCase in code, real column names only when you address the DB directly
|
|
36
|
+
|
|
37
|
+
Everything the plugin exposes is a **camelCase schema field name**. The database is free to name
|
|
38
|
+
its columns however it likes: snake_case columns (`organization_id`) are converted to camelCase
|
|
39
|
+
(`organizationId`) for the code, and columns already in camelCase (e.g. betterauth) are preserved
|
|
40
|
+
verbatim through the `colMap` the CLI generates. Either way `Schema.col('organizationId')` returns
|
|
41
|
+
the real column — the mapping lives in the generated Schema, so no naming convention is imposed on
|
|
42
|
+
the database and none has to be remembered. One rule: **write camelCase everywhere except where
|
|
43
|
+
you name a column to the database yourself.**
|
|
44
|
+
|
|
45
|
+
| camelCase — schema field names | real column names — whatever the table uses |
|
|
46
|
+
|---|---|
|
|
47
|
+
| every request and response body: `filters`, `conditions`, `orderBy`, `selectComputed`, `main`, secondaries | `db.*` (QueryClient) calls: record keys, `where`, `conflictKeys` — and the real table name (`Schema.tableName`), not the schema variable |
|
|
48
|
+
| `defineTable` keys: `primary`, `excludeFromCreation`, `readExclude`, `schemaOverrides`, `buildUpsertRule(Schema, […])` | `tenantScope`: `column`, `anyOf[…]`, `through.localField` / `through.foreignField` |
|
|
49
|
+
| `buildRelation(Main, mainField, Join, joinField)` | any SQL string you write yourself — `extendedCondition`, `computedFields.expr`: get the column with `Schema.col('field')` or `qiCol('field')`, never type it literally |
|
|
50
|
+
| hooks and validators — `validate`, `beforeInsert`/`afterInsert`, `beforeUpdate`/`afterUpdate`, `beforeDelete`/`afterDelete`, `afterRead` — receive and mutate camelCase records | |
|
|
51
|
+
| `sqlApi.search / get / insert / update / delete / bulkUpsert / bulkDelete` — same shapes as the HTTP bodies | |
|
|
52
|
+
|
|
53
|
+
Two consequences worth remembering: a `tenantScope` names the table's own columns (as in
|
|
54
|
+
`{ anyOf: ['requester_agent_id', 'target_agent_id'] }` for a snake_case table) while the client
|
|
55
|
+
still anchors the write by sending `requesterAgentId`; and `sqlApi` and `db` are **not**
|
|
56
|
+
interchangeable — `sqlApi` is the high-level API (camelCase, hooks, tenant, joins), `db` is the SQL
|
|
57
|
+
layer underneath it (real column names, no hooks, no tenant).
|
|
58
|
+
|
|
9
59
|
## Documentation
|
|
10
60
|
|
|
11
61
|
- **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)** — **READ FIRST when migrating.** Maps the old join API (`joinFilters`, `joins`, `joinGroups`) to the new alias-based one (`joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`). Covers `buildRelation` new options-object signature (alias defaults to `joinSchema.tableName`, plus `selection` and `unique`), request/response key renames, secondaries key renames, dotted notation rules, and validation/400 errors. Common to backend and frontend.
|
package/AGENTS_BACKEND.md
CHANGED
|
@@ -4,6 +4,34 @@ How to set up schemas, tables, and configure the plugin.
|
|
|
4
4
|
|
|
5
5
|
> ⚠️ **Migrating from a previous version?** The join API was redesigned (no backward compat). See **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)** for the full migration guide — request/response key renames, `buildRelation` signature, and validation rules. Common to backend and frontend.
|
|
6
6
|
|
|
7
|
+
## Section map
|
|
8
|
+
|
|
9
|
+
This file is long — jump, don't scan. Every supported behavior is described here or in the
|
|
10
|
+
files [AGENTS.md](./AGENTS.md) links to; the compiled `dist/` is not documentation and must not
|
|
11
|
+
be read to answer a question about the API.
|
|
12
|
+
|
|
13
|
+
| Section | Answers |
|
|
14
|
+
|---|---|
|
|
15
|
+
| [Setup Workflow](#setup-workflow) | install, `sqlapi.config.ts`, env vars, generating Schemas and tables, registering the plugin |
|
|
16
|
+
| [Plugin Options](#plugin-options) | every `register()` option with its default, and how the connection is picked up (`fastify.pg` / `fastify.mysql`) |
|
|
17
|
+
| [defineTable() — Complete Reference](#definetable--complete-reference) | every table key, composite PKs, `buildRelation`, `extraFilters` + `extendedCondition` |
|
|
18
|
+
| [Computed Fields](#computed-fields-extension-system) | virtual SQL-expression fields usable in filters, conditions, orderBy, `selectComputed` |
|
|
19
|
+
| [ConditionBuilder API](#conditionbuilder-api) | building WHERE fragments by hand |
|
|
20
|
+
| [SqlApi](#sqlapi--programmatic-high-level-api) | **querying from your own routes, scripts and jobs** — `app.sqlApi`, `createSqlApi` |
|
|
21
|
+
| [QueryClient API](#queryclient-api) | the raw SQL layer under SqlApi — `db.*` inside hooks |
|
|
22
|
+
| [Swagger](#swagger) | exposing the generated docs |
|
|
23
|
+
| [Multi-Tenant Filtering](#multi-tenant-filtering) | `tenantScope` direct / indirect / `anyOf`, `getTenantId`, admin bypass |
|
|
24
|
+
| [Views](#views) | views and materialized views as table configs, the primary key a view cannot declare, why writes start off |
|
|
25
|
+
| [Key Conventions](#key-conventions) | casing, nullability, `excludeFromCreation`, `readExclude`, `upsertMap`, `schemaOverrides`, `afterRead`, error shape, request limits |
|
|
26
|
+
| [Common Backend Patterns](#common-backend-patterns) | auth, custom validation, audit fields, encryption at rest, full-text search, per-prefix access levels |
|
|
27
|
+
| [Re-exports](#re-exports) | what the package re-exports, so nothing extra has to be installed |
|
|
28
|
+
| [FAQ / Gotchas](#faq--gotchas) | schema exports, prefix behavior, TypeBox conflicts, dialect differences |
|
|
29
|
+
|
|
30
|
+
**Casing in one line**: everything you write is camelCase (schema field names) except raw SQL,
|
|
31
|
+
`tenantScope` columns and `db.*` record keys, which name the real columns — in whatever case the
|
|
32
|
+
table uses. Full table in
|
|
33
|
+
[AGENTS.md](./AGENTS.md#naming-camelcase-in-code-real-column-names-only-when-you-address-the-db-directly).
|
|
34
|
+
|
|
7
35
|
## Setup Workflow
|
|
8
36
|
|
|
9
37
|
### 1. Install
|
|
@@ -23,16 +51,19 @@ npm install @fastify/swagger @fastify/swagger-ui
|
|
|
23
51
|
Create `sqlapi.config.ts` (or `.js`) in the project root. This config is used **only by the CLI** (`sqlapi-generate-schema` / `sqlapi-generate-tables`), NOT at runtime by the Fastify plugin.
|
|
24
52
|
|
|
25
53
|
```typescript
|
|
26
|
-
// sqlapi.config.ts
|
|
54
|
+
// sqlapi.config.ts
|
|
27
55
|
export default {
|
|
28
56
|
outputDir: './src/db', // base directory for generated files (default: './src/db')
|
|
29
57
|
schema: 'public', // DB schema to introspect (default: 'public', PostgreSQL only)
|
|
30
58
|
dialect: 'postgres', // 'postgres' | 'mysql' | 'mariadb' (default: 'postgres')
|
|
31
59
|
// envFile: '../../.env', // path to .env file, relative to cwd (default: '.env')
|
|
60
|
+
// excludeTables: ['knex_*'],// tables invisible to schema generation (see below)
|
|
32
61
|
};
|
|
33
62
|
```
|
|
34
63
|
|
|
35
|
-
That's it — only these
|
|
64
|
+
That's it — only these five fields exist. **No connection string, no migration path.** If the file is missing, defaults are used. The `dialect` can also be passed via CLI flag `--dialect mysql`. The `envFile` is useful in monorepo setups where the `.env` lives at the repo root.
|
|
65
|
+
|
|
66
|
+
**`excludeTables`** keeps migration bookkeeping, PostGIS internals and the like out of generation: exact table names or `*` globs, matched against the **DB table name** (not the generated schema name), with everything else literal. An excluded table is invisible to the generator — its schema file is not created, and one generated by an earlier run is removed as an orphan on the next full run, exactly like the schema of a dropped table. See [ADR 0009](./docs/adr/0009-schema-exclusion-config-blacklist.md).
|
|
36
67
|
|
|
37
68
|
### 3. Configure database connection (env vars)
|
|
38
69
|
|
|
@@ -69,7 +100,7 @@ npx sqlapi-generate-schema # PostgreSQL (default)
|
|
|
69
100
|
npx sqlapi-generate-schema --dialect mysql # MySQL/MariaDB
|
|
70
101
|
```
|
|
71
102
|
|
|
72
|
-
This introspects the database and generates one `Schema*.ts` file per table in `outputDir/schemas/`. These files are auto-generated and should not be manually edited. They contain TypeBox field definitions, `col()`
|
|
103
|
+
This introspects the database and generates one `Schema*.ts` file per table in `outputDir/schemas/`. These files are auto-generated and should not be manually edited. They contain TypeBox field definitions, `col()` mapping each camelCase field to its real column, and validation schemas. MySQL/MariaDB requires `mysql2` as a peer dependency.
|
|
73
104
|
|
|
74
105
|
### 5. Generate tables template
|
|
75
106
|
|
|
@@ -188,6 +219,52 @@ await app.register(async (instance) => {
|
|
|
188
219
|
|
|
189
220
|
---
|
|
190
221
|
|
|
222
|
+
## Plugin Options
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
await app.register(fastifyAutoSqlApi, {
|
|
226
|
+
DbTables: dbTables, // Required — your table definitions
|
|
227
|
+
dialect: 'postgres', // Optional — 'postgres' | 'mysql' | 'mariadb' (default: 'postgres')
|
|
228
|
+
prefix: '/api', // Optional — URL prefix for all routes
|
|
229
|
+
swagger: true, // Optional — enable Swagger UI (or pass SwaggerOptions)
|
|
230
|
+
onRequests: [authMiddleware], // Optional — global hooks applied to every route
|
|
231
|
+
getTenantId: (req) => id, // Optional — multi-tenant function
|
|
232
|
+
maxItemsPerPage: 1000, // Optional — cap on search page size (default: 1000)
|
|
233
|
+
maxBulkItems: 1000, // Optional — cap on bulk array length (default: 1000)
|
|
234
|
+
debug: true, // Optional — log all SQL queries and params
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
| Option | Type | Required | Description |
|
|
239
|
+
|--------|------|----------|-------------|
|
|
240
|
+
| `DbTables` | `Record<string, ITable>` | Yes | Table configurations |
|
|
241
|
+
| `dialect` | `'postgres' \| 'mysql' \| 'mariadb'` | No | DB dialect (default: `'postgres'`) |
|
|
242
|
+
| `prefix` | `string` | No | URL prefix (e.g. `/api`) |
|
|
243
|
+
| `swagger` | `boolean \| SwaggerOptions` | No | Enable Swagger UI |
|
|
244
|
+
| `onRequests` | `Function[]` | No | Global auth/middleware hooks |
|
|
245
|
+
| `getTenantId` | `(req) => id \| null` | No | Tenant resolver for multi-tenant |
|
|
246
|
+
| `maxItemsPerPage` | `number` | No | Max search page size, and the row `LIMIT` applied when no paginator is sent (default: `1000`) |
|
|
247
|
+
| `maxBulkItems` | `number` | No | Max number of items accepted by the bulk endpoints (default: `1000`) |
|
|
248
|
+
| `debug` | `boolean` | No | Log all SQL queries to console (server-side only — it never changes a response) |
|
|
249
|
+
| `exposeDebugInfo` | `boolean` | No | **Development only.** Attach the driver error (message, `code`, `constraint`, stack) to `500` responses as `debugInfo` (default: `false`) |
|
|
250
|
+
|
|
251
|
+
The plugin picks up the connection from the Fastify instance: `fastify.pg` for PostgreSQL
|
|
252
|
+
(what `@fastify/postgres` decorates) or `fastify.mysql` for MySQL/MariaDB. For MySQL,
|
|
253
|
+
decorate a `mysql2/promise` pool (e.g. via `@fastify/mysql` with `promise: true`, or manually)
|
|
254
|
+
and pass `dialect: 'mysql'` or `'mariadb'`:
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
import mysql from 'mysql2/promise';
|
|
258
|
+
|
|
259
|
+
const pool = mysql.createPool({ host: '127.0.0.1', user: 'myuser', password: '...', database: 'mydb' });
|
|
260
|
+
app.decorate('mysql', pool);
|
|
261
|
+
app.addHook('onClose', async () => { await pool.end(); });
|
|
262
|
+
|
|
263
|
+
await app.register(fastifyAutoSqlApi, { DbTables: dbTables, dialect: 'mysql' });
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
191
268
|
## defineTable() — Complete Reference
|
|
192
269
|
|
|
193
270
|
```typescript
|
|
@@ -332,6 +409,14 @@ export const TableCustomer = defineTable({
|
|
|
332
409
|
afterBulkDelete: async (db, req, deletedIds) => {
|
|
333
410
|
// Called ONCE with the ids ACTUALLY deleted (may be a subset of the requested ids).
|
|
334
411
|
},
|
|
412
|
+
afterRead: async (db, req, rows, ctx) => {
|
|
413
|
+
// The read-side hook: turn the stored representation back into the API one.
|
|
414
|
+
// Called ONCE per result set (batch), with camelCase rows mutated IN PLACE.
|
|
415
|
+
// Runs for get, search, joinMultiple and joinLeft — including when THIS table's rows
|
|
416
|
+
// arrive through a join declared on another table (ctx.source / ctx.alias say which).
|
|
417
|
+
// Not called on joinGroup (aggregates, not rows) nor when the read returned nothing.
|
|
418
|
+
for (const row of rows) row.ssn = decrypt(row.ssn);
|
|
419
|
+
},
|
|
335
420
|
|
|
336
421
|
// OPERATIONS — whitelist of auto-generated HTTP routes for this table.
|
|
337
422
|
// Omitted = ALL operations exposed (default-open!). Does not affect programmatic sqlApi.*.
|
|
@@ -589,6 +674,13 @@ All values are parameterized (`$1, $2, ...`), never interpolated.
|
|
|
589
674
|
|
|
590
675
|
Use `SqlApi` to perform CRUD operations from custom routes with the same capabilities as the auto-generated endpoints. The internal auto-generated routes also use `SqlApi`, guaranteeing a single code path.
|
|
591
676
|
|
|
677
|
+
It takes and returns **camelCase schema field names**, exactly like the HTTP bodies documented in
|
|
678
|
+
[AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md) — `sqlApi.search('order', { filters: { customerId: 3 } })`,
|
|
679
|
+
never `customer_id`. Hooks, validation, computed fields and tenant scoping all run, so this is the
|
|
680
|
+
right entry point for application code. Drop to [`QueryClient`](#queryclient-api) only for SQL that
|
|
681
|
+
has no equivalent here — it is the layer underneath, with none of that behavior and with real DB
|
|
682
|
+
column names.
|
|
683
|
+
|
|
592
684
|
### Using `app.sqlApi` (recommended)
|
|
593
685
|
|
|
594
686
|
After registering the plugin, `app.sqlApi` is available everywhere — no extra configuration needed:
|
|
@@ -671,6 +763,15 @@ The `request` parameter is optional. Pass it when you need tenant resolution or
|
|
|
671
763
|
|
|
672
764
|
## QueryClient API
|
|
673
765
|
|
|
766
|
+
The SQL layer **underneath** [`SqlApi`](#sqlapi--programmatic-high-level-api): it executes what you
|
|
767
|
+
give it and nothing else — no hooks, no validation, no computed fields, no tenant scoping. Reach for
|
|
768
|
+
it only when the operation has no `sqlApi` equivalent; anything expressed as CRUD belongs there.
|
|
769
|
+
|
|
770
|
+
It speaks the **database's own names**: real table names (`Schema.tableName`, not the schema
|
|
771
|
+
variable) and real column names — get them from `Schema.col('customerId')` rather than assuming a
|
|
772
|
+
convention, since the columns may be snake_case or already camelCase. Passing a schema field name
|
|
773
|
+
the mapping would have translated produces a SQL error at runtime, not a validation error.
|
|
774
|
+
|
|
674
775
|
Available in hooks via the `db` parameter:
|
|
675
776
|
|
|
676
777
|
```typescript
|
|
@@ -687,7 +788,7 @@ db.expression(value); // Raw S
|
|
|
687
788
|
|
|
688
789
|
`pkCol` can be a string (`'id'`) or an array (`['agent_id', 'team_id']`) for composite PKs. PostgreSQL/MariaDB use `RETURNING`, MySQL uses `insertId`.
|
|
689
790
|
|
|
690
|
-
Record keys are
|
|
791
|
+
Record keys are real column names (`Schema.col('field')`). Values are parameterized.
|
|
691
792
|
|
|
692
793
|
---
|
|
693
794
|
|
|
@@ -733,6 +834,26 @@ await app.register(fastifyAutoSqlApi, {
|
|
|
733
834
|
});
|
|
734
835
|
```
|
|
735
836
|
|
|
837
|
+
Every column named in a `tenantScope` — `column`, the entries of `anyOf`, `through.localField`
|
|
838
|
+
and `through.foreignField` — is a **real DB column name**, not a camelCase schema field. This is
|
|
839
|
+
the one key of `defineTable` where the convention flips, and it is deliberate: a scope is applied
|
|
840
|
+
to the SQL, *after* the payload has been converted, and `through.foreignField` belongs to a parent
|
|
841
|
+
table that may have no config, no `colMap`, or no generated schema at all. Same category as `db.*`
|
|
842
|
+
and `extendedCondition` — names that go straight to the database.
|
|
843
|
+
|
|
844
|
+
Write them as the table declares them (`organization_id` below, but `organizationId` on a
|
|
845
|
+
camelCase table); clients keep sending the camelCase field name either way, including when
|
|
846
|
+
anchoring an `anyOf` write.
|
|
847
|
+
|
|
848
|
+
`defineTable` catches the mistake this exception invites: naming the schema **field** whose real
|
|
849
|
+
column is something else (`organizationId` where the column is `organization_id`) throws at
|
|
850
|
+
startup, on all four positions, checking `through.foreignField` against the parent schema. It
|
|
851
|
+
cannot check that a column *exists* — a hand-written schema may legitimately omit it — but it can
|
|
852
|
+
tell you that you named a field. Without it the mistake surfaces as
|
|
853
|
+
`column "organizationId" does not exist` on every request to the table: an opaque `500` that fails
|
|
854
|
+
closed and says nothing. On a camelCase database the check raises nothing, since `col()` is the
|
|
855
|
+
identity there.
|
|
856
|
+
|
|
736
857
|
### Direct tenant (column on the table itself)
|
|
737
858
|
|
|
738
859
|
```typescript
|
|
@@ -842,6 +963,51 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
|
|
|
842
963
|
|
|
843
964
|
---
|
|
844
965
|
|
|
966
|
+
## Views
|
|
967
|
+
|
|
968
|
+
A view is a **table config** like any other — same `defineTable`, same `Table*.ts` file, same
|
|
969
|
+
routes. There is no `View*` anything: searching, filtering, ordering, pagination, joins and
|
|
970
|
+
computed fields all work through the same code path as a base table. Materialized views are
|
|
971
|
+
included, PostgreSQL ones read from `pg_catalog` since `information_schema` does not describe
|
|
972
|
+
them.
|
|
973
|
+
|
|
974
|
+
What differs is what the generator can read from the database, and it is worth knowing before
|
|
975
|
+
you accept the file it writes.
|
|
976
|
+
|
|
977
|
+
**The primary key is not declared.** A view carries no `PRIMARY KEY` constraint, so
|
|
978
|
+
`sqlapi-generate-schema` has nothing to report and the table generator **will not guess one
|
|
979
|
+
from the column types** — on `SELECT status, count(*) AS order_count ... GROUP BY status` that
|
|
980
|
+
would take the count as the key, and `get/:id`, `update` and `delete` would then address rows
|
|
981
|
+
by it. Instead:
|
|
982
|
+
|
|
983
|
+
- the view has an `id` column → it is used, and the file says the key was *inferred, not read
|
|
984
|
+
from the database*. Check that it is really unique in the view before exposing
|
|
985
|
+
`get`/`update`/`delete`.
|
|
986
|
+
- no plausible column → `primary: 'TODO_pick_a_unique_column'` plus `operations: ['search']`
|
|
987
|
+
and a real `defaultOrder`. **This config works as generated**: search reads `defaultOrder`,
|
|
988
|
+
never the primary key. The TODO is what you replace to widen `operations`, not a defect.
|
|
989
|
+
|
|
990
|
+
`defineTable` enforces that: a `primary` naming a field the schema does not have throws **at
|
|
991
|
+
startup**, unless the table exposes only `search` and declares its own `defaultOrder`.
|
|
992
|
+
|
|
993
|
+
**Writes start off, but are not forbidden.** A view's generated config is
|
|
994
|
+
`operations: ['search', 'get']`. An aggregating view rejects every INSERT and UPDATE at the
|
|
995
|
+
database (`cannot insert into view ...`, a raw `500`), while a view simple enough for the engine
|
|
996
|
+
to make updatable accepts them on both PostgreSQL and MySQL — using one as a projection or
|
|
997
|
+
permission layer is a supported pattern. The read-only start is the *generated template*, not a
|
|
998
|
+
runtime rule: remove or widen `operations` and the write routes come back.
|
|
999
|
+
|
|
1000
|
+
**Nullability differs between engines** for the same view: PostgreSQL reports every view column
|
|
1001
|
+
as nullable, MySQL reports the real nullability of the underlying expression. The generated
|
|
1002
|
+
Schema therefore differs by dialect for one and the same view. It affects write bodies only, and
|
|
1003
|
+
a view is not usually written to.
|
|
1004
|
+
|
|
1005
|
+
`sqlapi-generate-schema` prints the views it found and both assumptions above — read that
|
|
1006
|
+
output, it is the part that does not depend on anyone opening the generated file. See
|
|
1007
|
+
[ADR 0016](./docs/adr/0016-a-view-is-a-table-config.md).
|
|
1008
|
+
|
|
1009
|
+
---
|
|
1010
|
+
|
|
845
1011
|
## Key Conventions
|
|
846
1012
|
|
|
847
1013
|
- **camelCase in API, any case in DB**: all request/response fields are camelCase. The plugin converts via `col()` and `colMap`. For snake_case DB columns (default), conversion is automatic. For camelCase DB columns (e.g. betterauth), the CLI generates a `colMap` that preserves the original column names — no conversion needed. Manual schemas without `colMap` fall back to `toUnderscore()`.
|
|
@@ -849,11 +1015,15 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
|
|
|
849
1015
|
- **Nullable columns use `Nullable(T)` (type-array form)**: the generator emits `Type.Optional(Nullable(T))` for nullable columns — `Nullable` (exported by the package) produces `{ type: ['integer', 'null'] }`. Do NOT replace it with `Type.Union([T, Type.Null()])`: under Fastify's default Ajv `coerceTypes` a union corrupts values through its branches (`null` → `0` with the Null branch last, `0`/`""` → `null` with it first), while the type array validates and serializes NULL correctly. A bare `Type.Optional(T)` is also wrong: NULL serializes as `0`/`""`. In `filters`, an explicit `null` on a schema field builds `WHERE col IS NULL`.
|
|
850
1016
|
- **`excludeFromCreation`**: **IMPORTANT** — auto-increment PKs (e.g. `id` serial/auto_increment) MUST be listed here, otherwise INSERT will try to send them and fail. The CLI auto-detects this and adds it by default. Also useful for `createdAt`/`updatedAt` columns managed by DB defaults or hooks. It strips **client-supplied** values only: the payload is sanitized before `beforeInsert` runs, so a value the hook assigns to an excluded field (e.g. a server-generated TEXT id) DOES reach the INSERT. Same for the engine's FK auto-fill on secondaries — an excluded FK column does not suppress it. It is an **ergonomics tool for creation, NOT a field-level security mechanism**: it does not apply to updates — every Schema field is updatable by default, by design. Field-level update rules (`isAdmin`, roles, owner/tenant columns, state fields) are product logic: enforce them in `beforeUpdate` (silent strip) or `validate` (loud 400), or move the privileged transition to a dedicated endpoint and keep it off the auto routes via `operations`.
|
|
851
1017
|
- **`readExclude`**: fields hidden from every read — not projected by search/get, omitted from read response schemas and from this table's default (`*`) join selection when it is a join target. Referencing one from `filters`, `conditions`, `orderBy`, `joinGroup` aggregations or an explicit join `selection` returns 400: hiding a field from output while allowing it to be filtered would leak its value by bisection. **Writes are unaffected** — insert/update/bulk still accept the field, so a column can be writable but never readable (password hash, access token). Primary keys cannot be excluded. Complementary to trimming the Schema, which removes the column from reads *and* writes. It is **per table and static**: a column hidden here is hidden from every caller, including admins. To keep a column readable on the table's own routes but not through a relation, trim the Schema the *relation* is declared with instead — see the read-grant warning under [buildRelation](#buildrelation-signature).
|
|
1018
|
+
- **`writeExclude`**: the write-side counterpart of `readExclude` — fields removed from the insert, update and bulk-upsert bodies (main *and* secondaries) and dropped again inside the engines, which `sqlApi.*` reaches without those schemas. Reads are untouched: the field is still projected, filterable and orderable. It exists for the columns the database refuses to be told about. **A `GENERATED ALWAYS AS (<expr>)` column is already covered without any config**: the CLI records it in the Schema's `generatedFields`, and both engines reject a statement that so much as names such a column, so offering it as writable could only turn a client mistake into a `500`. The `writeExclude` key is for the rest — a column a trigger owns, one a migration is about to drop. It is **static and applies to everyone**, admins included; a rule that depends on *who* is asking is product logic and belongs in `beforeUpdate` (silent strip) or `validate` (loud 400), per [ADR 0004](./docs/adr/0004-updates-always-open.md). Not to be confused with `excludeFromCreation`, which whitelists **client input on insert only** and deliberately lets a hook assign the field afterwards — `writeExclude` runs **after** the hooks, because the column refuses the value whoever assigned it. `defineTable` rejects a field that is not in the schema, the primary key (the update body identifies the row with it) and a field that is also in `readExclude` (neither readable nor writable means: remove it from the Schema). See [ADR 0015](./docs/adr/0015-non-writable-columns.md), which narrows [ADR 0004](./docs/adr/0004-updates-always-open.md).
|
|
1019
|
+
- **Database-computed and identity columns are detected by the CLI**: `sqlapi-generate-schema` reads `is_generated` (PostgreSQL) / `EXTRA` (MySQL, `VIRTUAL GENERATED` and `STORED GENERATED`) and prints the columns it found, per table, before writing anything — read that output. Generated columns land in the Schema's `generatedFields` and are named in a comment in the generated `Table*.ts`. Separately, `GENERATED ... AS IDENTITY` (PostgreSQL) has no `column_default` to reveal that the database fills it in: it is now flagged like MySQL's `AUTO_INCREMENT`, so the field is Optional and the CLI lists it under `excludeFromCreation` instead of emitting a mandatory primary key the database then refuses.
|
|
852
1020
|
- **`upsertMap`**: when present for a schema, INSERT becomes upsert. PostgreSQL: `ON CONFLICT (...) DO UPDATE`. MySQL/MariaDB: `ON DUPLICATE KEY UPDATE`. Applies to both main and secondary tables.
|
|
853
|
-
- **`schemaOverrides`**:
|
|
1021
|
+
- **`schemaOverrides`**: narrow an auto-generated field to a stricter TypeBox type (e.g. `{ email: Type.String({ format: 'email' }) }`) where `information_schema` could only report `text`. **Write bodies only** — insert, update, bulk-upsert. An override rules what the API accepts *from now on*, while the rows already stored predate it: a column narrowed to `format: 'email'` today holds whatever was accepted last year, and one made mandatory today is `NULL` on every pre-existing row. Narrowing the response would publish a promise nobody can retroactively make true, so responses and the `filters` map keep the generated type. (Nothing would break at runtime either way — Fastify does not validate responses, and `fast-json-stringify` ignores `format`/`pattern`/`minLength`/`minimum`/`enum` entirely; it acts only on `type`, by coercion.) The override is taken **verbatim**: no `Type.Optional` makes the field mandatory, no `Nullable` rejects an explicit `null`, whatever the column allows — which is how you make a column the DB had to leave nullable (a new column on a populated table) mandatory going forward. The original Schema file is never modified; the primary key is mandatory in the update body (it identifies the row); overrides appear in Swagger.
|
|
1022
|
+
- **`afterRead`**: the read-side counterpart of `beforeInsert`/`beforeUpdate` — the place to decrypt a column, unpack a blob, rescale a stored unit. Called **once per result set** with camelCase rows mutated **in place** (a hook that calls a remote KMS gets one invocation for the page, not one per row), and awaited. Declared on the table that **owns** the column, and it follows a join: the hook runs wherever that table's rows surface, including through a `joinMultiple`/`joinLeft` declared on another table — `ctx.source` (`'get' | 'search' | 'joinMultiple' | 'joinLeft'`) and `ctx.alias` say which. Not called on `joinGroup` (aggregates, not table rows) nor on an empty result. Rows cannot be added or removed — the pagination `COUNT` has already run, so dropping rows would report a `total` that does not match; narrowing what a caller sees is `filters`/`tenantScope`/`readExclude`. On the main table it runs **after** the join side queries, which correlate on the values the database returned: a hook rewriting the column a relation joins on does not break the correlation. Two things it does not reach, by design: `filters`, `orderBy` and `compute*` run in SQL against the **stored** value (searching an encrypted column searches the ciphertext), and it transforms **values, not types** — the response is serialized against the generated schema, so a hook returning an object where the column is `text` yields `"[object Object]"`, and one returning `12.34` on an `integer` column yields `12`. Decrypting `text`→`text` or rescaling `numeric`→`numeric` is what it is for; if the stored and API types genuinely differ, that is a custom route.
|
|
854
1023
|
- **Validation receives camelCase**: `validate` receives the original camelCase record (as sent by the client) and secondaries. Field names match the schema definition, with full TypeScript inference (`main.startDate`, not `main.start_date`). It returns `ValidationError[]` — tuples of `[field, code]` or `[field, code, message]`. If any errors are returned, the request is rejected with 400 before hooks or SQL execute.
|
|
855
1024
|
- **`validateBulk` replaces `validate` in bulk**: when `validateBulk` is defined, it is called once with all items and per-item `validate` is skipped. This allows optimized batch queries instead of N individual checks. When only `validate` is defined, it runs per-item as fallback.
|
|
856
|
-
- **All hooks and validators receive camelCase records**: `validate` and the whole hook matrix (`beforeInsert`/`afterInsert`, `beforeUpdate`/`afterUpdate`, `beforeDelete`/`afterDelete`, `beforeBulkDelete`/`afterBulkDelete`) get records keyed by schema field names (camelCase). Mutations propagate to the SQL (plugin converts to DB column format via `colMap` after the hook). The engine internally uses `snakecaseRecord(..., schema)` after user mutations to map field names to actual DB columns.
|
|
1025
|
+
- **All hooks and validators receive camelCase records**: `validate` and the whole hook matrix (`beforeInsert`/`afterInsert`, `beforeUpdate`/`afterUpdate`, `beforeDelete`/`afterDelete`, `beforeBulkDelete`/`afterBulkDelete`, `afterRead`) get records keyed by schema field names (camelCase). Mutations propagate to the SQL (plugin converts to DB column format via `colMap` after the hook). The engine internally uses `snakecaseRecord(..., schema)` after user mutations to map field names to actual DB columns.
|
|
1026
|
+
- **Secondaries run the child table's `beforeInsert`**: a child row written alongside a main record is a write like any other — the child's `excludeFromCreation`, its `tenantScope` and its `beforeInsert` all apply, in that order, before the engine auto-fills the FK to main (which always wins over anything the payload or the hook put there). Its `validate` and `afterInsert` deliberately do **not** run: they change when a rejection or a side effect fires, which belongs to the host's write. See [ADR 0014](./docs/adr/0014-what-follows-a-write-join.md).
|
|
857
1027
|
- **Filters validation**: TypeBox schemas use `additionalProperties: false`. By default Fastify strips unknown fields silently. For 400 errors on unknown filters: `Fastify({ ajv: { customOptions: { removeAdditional: false } } })`.
|
|
858
1028
|
- **Write-body whitelist**: insert/update/bulk-upsert bodies (`main` + secondaries items) are `additionalProperties: false`. Unknown properties are rejected with 400 — only columns in the generated Schema (as narrowed by `schemaOverrides`/`excludeFromCreation`) can be written, so no mass assignment of unexposed columns. Trim the Schema to keep sensitive columns unwritable.
|
|
859
1029
|
- **Error responses**: errors the plugin raises itself are `4xx` with messages written for clients (validation, not found, tenant). Anything else — a driver constraint violation, a hook throwing without a `statusCode` — answers a fixed `{"statusCode":500,"error":"Internal Server Error","message":"Internal Server Error"}`: driver messages name tables, columns and constraints, and they stay on the server. **No status code is remapped** (a unique violation is a `500`, not a `409` — that mapping is product logic). The body carries `requestId` (the `reqId` Fastify logs on every line) so the real error is one grep away in the log, where it is also written via `request.log.error` — and attached as `err.cause`, so a consumer `setErrorHandler` can map `err.cause.code === '23505'` to `409` itself. **`exposeDebugInfo: true` is the only way to get the driver detail on the wire** — set it while developing, where a client building against the API cannot always read your log: the `500` then also carries `debugInfo` (`message`, `code`, `constraint`, `detail`, `stack`), additively, so the four fields above keep their production shape. Nothing else flips it: not `debug` (server-side only, and it belongs to whoever registers the plugin), not `NODE_ENV`. Errors thrown by `onRequests` hooks run before the handler and are unaffected.
|
|
@@ -1041,6 +1211,47 @@ const TableUser = defineTable({
|
|
|
1041
1211
|
For privileged transitions (promote to admin, move across tenants) prefer a dedicated
|
|
1042
1212
|
endpoint with its own auth/audit, and keep the operation off the auto routes via `operations`.
|
|
1043
1213
|
|
|
1214
|
+
### Encrypt a column at rest (storage representation ≠ API representation)
|
|
1215
|
+
|
|
1216
|
+
Three declarations on the table that owns the column — in on the write hooks, out on the read
|
|
1217
|
+
hook — and nothing at the call sites:
|
|
1218
|
+
|
|
1219
|
+
```typescript
|
|
1220
|
+
const TablePatient = defineTable({
|
|
1221
|
+
primary: 'id',
|
|
1222
|
+
...exportTableInfo(SchemaPatient),
|
|
1223
|
+
|
|
1224
|
+
beforeInsert: async (db, req, record) => {
|
|
1225
|
+
if (record.ssn != null) record.ssn = encrypt(record.ssn);
|
|
1226
|
+
},
|
|
1227
|
+
beforeUpdate: async (db, req, fields) => {
|
|
1228
|
+
if (fields.ssn != null) fields.ssn = encrypt(fields.ssn);
|
|
1229
|
+
},
|
|
1230
|
+
afterRead: async (db, req, rows) => {
|
|
1231
|
+
for (const row of rows) {
|
|
1232
|
+
if (row.ssn != null) row.ssn = decrypt(row.ssn);
|
|
1233
|
+
}
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
```
|
|
1237
|
+
|
|
1238
|
+
`afterRead` covers `GET /rest/patient/:id`, `POST /search/patient`, and every `joinMultiple` /
|
|
1239
|
+
`joinLeft` that reaches `patient` from another table's search — the hook belongs to the table
|
|
1240
|
+
that owns the column, so a relation declared elsewhere needs no extra wiring. The same holds on
|
|
1241
|
+
the way in: a patient written as a **secondary** of another table's insert runs this
|
|
1242
|
+
`beforeInsert` too.
|
|
1243
|
+
|
|
1244
|
+
What the plugin does *not* do for you, because it cannot know your cipher:
|
|
1245
|
+
|
|
1246
|
+
- **searching**: `filters: { ssn: '123-45' }` compares the plaintext against the ciphertext in
|
|
1247
|
+
SQL and matches nothing. With a deterministic cipher, encrypt the value yourself in
|
|
1248
|
+
`extendedCondition`; with a randomized one, that column is not searchable at all. `orderBy`
|
|
1249
|
+
and `computeMin/Max` sort and aggregate the ciphertext.
|
|
1250
|
+
- **typing**: the hook transforms values, not types. The response is serialized against the
|
|
1251
|
+
generated schema, so returning an object where the column is `text` yields
|
|
1252
|
+
`"[object Object]"`. `text`→`text` and `numeric`→`numeric` transforms are the supported shape.
|
|
1253
|
+
- **aggregates**: `joinGroup` and `compute*` return values the hook never sees.
|
|
1254
|
+
|
|
1044
1255
|
### Full-text search filter
|
|
1045
1256
|
|
|
1046
1257
|
```typescript
|
|
@@ -1151,6 +1362,53 @@ loose generic tool (`table` enum from the manifest, free body) with the structur
|
|
|
1151
1362
|
|
|
1152
1363
|
---
|
|
1153
1364
|
|
|
1365
|
+
## Re-exports
|
|
1366
|
+
|
|
1367
|
+
The package re-exports commonly needed utilities so you don't need to install them separately:
|
|
1368
|
+
|
|
1369
|
+
```typescript
|
|
1370
|
+
import {
|
|
1371
|
+
Type, // from @sinclair/typebox
|
|
1372
|
+
type Static, // from @sinclair/typebox
|
|
1373
|
+
ConditionBuilder, // from node-condition-builder
|
|
1374
|
+
Expression, // from node-condition-builder
|
|
1375
|
+
|
|
1376
|
+
// Table configuration
|
|
1377
|
+
defineTable,
|
|
1378
|
+
exportTableInfo,
|
|
1379
|
+
buildRelation,
|
|
1380
|
+
buildUpsertRule,
|
|
1381
|
+
buildUpsertRules,
|
|
1382
|
+
Nullable, // type-array nullable schema (see Conventions)
|
|
1383
|
+
|
|
1384
|
+
// DB layer
|
|
1385
|
+
QueryClient, // raw SQL query helper
|
|
1386
|
+
createQueryClient, // factory with dialect string
|
|
1387
|
+
pgQueryable, // pg pool adapter
|
|
1388
|
+
mysqlQueryable, // mysql2 pool adapter
|
|
1389
|
+
|
|
1390
|
+
// Programmatic high-level API
|
|
1391
|
+
createSqlApi, // standalone SqlApi (for scripts/tests)
|
|
1392
|
+
setupSwagger, // manual Swagger registration
|
|
1393
|
+
} from 'fastify-auto-sqlapi';
|
|
1394
|
+
|
|
1395
|
+
import type {
|
|
1396
|
+
ValidationError, // [field, code] | [field, code, message]
|
|
1397
|
+
ValidatorFn,
|
|
1398
|
+
BulkValidatorFn,
|
|
1399
|
+
JoinDefinition,
|
|
1400
|
+
JoinRefFilter,
|
|
1401
|
+
JoinFetchRequest,
|
|
1402
|
+
JoinGroupRequest,
|
|
1403
|
+
SearchCondition,
|
|
1404
|
+
ConditionMethod,
|
|
1405
|
+
} from 'fastify-auto-sqlapi';
|
|
1406
|
+
```
|
|
1407
|
+
|
|
1408
|
+
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.
|
|
1409
|
+
|
|
1410
|
+
---
|
|
1411
|
+
|
|
1154
1412
|
## FAQ / Gotchas
|
|
1155
1413
|
|
|
1156
1414
|
### Schema files `default export` — do I need to register them?
|
package/AGENTS_FRONTEND.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
This file is the request GRAMMAR. For this deployment's tables/fields/aliases (the
|
|
4
4
|
vocabulary), fetch `GET {p}/agent/manifest.md` if available, or consult Swagger.
|
|
5
5
|
|
|
6
|
-
Rules: all request/response fields camelCase. `{p}` = deployment prefix (e.g. `/api`). Join keys are backend-declared ALIASES (default = joined table name; list per table in Swagger). Write responses are PK-only. Write bodies reject unknown properties (400) — send only real schema fields. Arrays for bulk are capped (default 1000/request; chunk client-side). Composite-PK tables have no get/delete/bulk-delete (404) — use search / update.
|
|
6
|
+
Rules: all request/response fields are camelCase schema field names — how the database names its columns is invisible here and irrelevant, the server maps them. `{p}` = deployment prefix (e.g. `/api`). Join keys are backend-declared ALIASES (default = joined table name; list per table in Swagger). Write responses are PK-only. Write bodies reject unknown properties (400) — send only real schema fields. Arrays for bulk are capped (default 1000/request; chunk client-side). Composite-PK tables have no get/delete/bulk-delete (404) — use search / update.
|
|
7
7
|
|
|
8
8
|
## Endpoints
|
|
9
9
|
|
package/BREAKING_CHANGES.md
CHANGED
|
@@ -536,3 +536,54 @@ getTenantId: (request) => request.user?.organizationId ?? null,
|
|
|
536
536
|
Then decide, for each open table that relates to a scoped one, whether the relation was meant to
|
|
537
537
|
be a read grant at all. If it was not, remove it from `allowedReadJoins` — a relation is the
|
|
538
538
|
grant, and the scope is only the cap on it.
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
# Breaking Change — secondaries run the child table's `beforeInsert`
|
|
543
|
+
|
|
544
|
+
## Why this changed
|
|
545
|
+
|
|
546
|
+
`processSecondaries` consulted the child table's configuration for its `excludeFromCreation` and
|
|
547
|
+
its `tenantScope`, but skipped its `beforeInsert`. A child table whose hook *transformed* a value
|
|
548
|
+
rather than adding one — encrypting a column, normalising a unit — therefore stored the raw
|
|
549
|
+
client value whenever its rows arrived as another table's secondaries, and correctly only through
|
|
550
|
+
its own route. Nothing failed at write time; the row read back as garbage later. See
|
|
551
|
+
[ADR 0014](./docs/adr/0014-what-follows-a-write-join.md).
|
|
552
|
+
|
|
553
|
+
## What changed
|
|
554
|
+
|
|
555
|
+
- A child table's **`beforeInsert` now runs** for rows written as `secondaries`, after that
|
|
556
|
+
table's `excludeFromCreation` strip and before the engine's FK auto-fill (which stays
|
|
557
|
+
authoritative). The child's `validate` and `afterInsert` still do not run.
|
|
558
|
+
- The **get response schema honours `readExclude`**: it previously advertised fields the engine
|
|
559
|
+
never selects. `schemaOverrides` is unchanged — still write bodies only.
|
|
560
|
+
- The **update body now requires the primary key**. It identifies the row (`PUT /rest/:table`
|
|
561
|
+
carries it in `main`), but the generated PK field is `Optional` and that modifier reached the
|
|
562
|
+
update body unchanged, so a request omitting `main.id` built its `WHERE` on `undefined`
|
|
563
|
+
instead of being rejected with a 400.
|
|
564
|
+
|
|
565
|
+
## Who is affected
|
|
566
|
+
|
|
567
|
+
- **A table of yours declares `beforeInsert` and is also a write-join target.** The hook now runs
|
|
568
|
+
on that path. If it was written assuming the table's own route — reading `req.params`, counting
|
|
569
|
+
invocations, calling an external service per record — it now runs where it did not before. It
|
|
570
|
+
receives the transaction connection, so what it writes rolls back with the host's write.
|
|
571
|
+
- **You send an update without `main.<pk>`.** It never identified a row — the `WHERE` was built
|
|
572
|
+
on `undefined` — and is now a 400.
|
|
573
|
+
- **A client of yours read a `readExclude`d field out of the get route's Swagger.** It was never
|
|
574
|
+
returned; only the documentation claimed it.
|
|
575
|
+
|
|
576
|
+
Not affected: tables with no `beforeInsert`, or none that are write-join targets. Nothing about
|
|
577
|
+
`schemaOverrides` changes.
|
|
578
|
+
|
|
579
|
+
## Migration
|
|
580
|
+
|
|
581
|
+
For a `beforeInsert` that must keep running on the table's own route only, guard on the context
|
|
582
|
+
it needs rather than on the path:
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
beforeInsert: async (db, req, record) => {
|
|
586
|
+
record.slug = slugify(record.name); // shaping: correct on both paths, leave it
|
|
587
|
+
if (req.routeOptions?.url?.startsWith('/rest/')) await notify(record); // side effect: narrow it
|
|
588
|
+
},
|
|
589
|
+
```
|