fastify-auto-sqlapi 0.1.9 → 0.1.10
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 +2 -0
- package/AGENTS_BACKEND.md +26 -1
- package/CHANGELOG.md +31 -0
- package/README.md +92 -14
- package/dist/lib/engine/write-helpers.d.ts +18 -4
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +31 -10
- package/docs/adr/0001-no-orm-raw-sql.md +38 -0
- package/docs/adr/0002-open-by-default.md +37 -0
- package/docs/adr/0003-bulk-not-transactional.md +35 -0
- package/docs/adr/0004-updates-always-open.md +46 -0
- package/docs/adr/0005-insert-pipeline-sanitize-before-hooks.md +51 -0
- package/docs/adr/0006-raw-db-errors.md +34 -0
- package/docs/adr/README.md +49 -0
- package/package.json +2 -1
package/AGENTS.md
CHANGED
|
@@ -14,6 +14,8 @@ The plugin generates REST endpoints (search, get, insert, update, delete, bulk u
|
|
|
14
14
|
|
|
15
15
|
- **[AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md)** — Generated endpoints overview, search (POST with body filters, conditions/advanced filters using ConditionBuilder methods, query params for ordering/pagination/aggregation), `joinMustExist` (EXISTS-based filtering by related tables), `joinMultiple` (fetch child rows), `joinGroup` (aggregations), `joinLeft` (embed N:1 parent inline), GET, INSERT (with secondaries), UPDATE (with secondaries + deletions), DELETE, bulk upsert, bulk delete, response shapes (PK-only), **validation errors** (structured field-level 400 responses), pagination, computed values.
|
|
16
16
|
|
|
17
|
+
- **[docs/adr/](./docs/adr/README.md)** — Architecture Decision Records. **Read before proposing a "fix" to a deliberate behavior**: open-by-default, non-transactional bulk, always-updatable fields, raw DB errors, insert-pipeline ordering. These are recorded decisions with rationale, not gaps.
|
|
18
|
+
|
|
17
19
|
## Quick Reference
|
|
18
20
|
|
|
19
21
|
### Generated endpoints
|
package/AGENTS_BACKEND.md
CHANGED
|
@@ -773,7 +773,7 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
|
|
|
773
773
|
|
|
774
774
|
- **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()`.
|
|
775
775
|
- **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
|
|
776
|
-
- **`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.
|
|
776
|
+
- **`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`.
|
|
777
777
|
- **`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.
|
|
778
778
|
- **`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.
|
|
779
779
|
- **`schemaOverrides`**: override auto-generated schema fields with stricter TypeBox types (e.g. `{ email: Type.String({ format: 'email' }) }`). Overrides are merged into the body schema for insert, update, and bulk-upsert. The original Schema file is never modified. In updates, overridden fields are still wrapped in Optional (validates only when present). Overrides appear in Swagger.
|
|
@@ -894,6 +894,31 @@ const TableCustomer = defineTable({
|
|
|
894
894
|
});
|
|
895
895
|
```
|
|
896
896
|
|
|
897
|
+
### Protect sensitive fields on update (isAdmin, roles, ownership)
|
|
898
|
+
|
|
899
|
+
Every Schema field is updatable by default — the plugin does not impose field-level rules
|
|
900
|
+
(see `excludeFromCreation` in Key Conventions). Encode yours in the hooks:
|
|
901
|
+
|
|
902
|
+
```typescript
|
|
903
|
+
const TableUser = defineTable({
|
|
904
|
+
primary: 'id',
|
|
905
|
+
...exportTableInfo(SchemaUser),
|
|
906
|
+
// Silent strip: non-admins simply cannot touch the flag
|
|
907
|
+
beforeUpdate: async (db, req, fields) => {
|
|
908
|
+
if (!req.user.isAdmin) delete fields.isAdmin;
|
|
909
|
+
},
|
|
910
|
+
// Or loud 400 via validate:
|
|
911
|
+
// validate: async (db, req, main) => {
|
|
912
|
+
// if (main.isAdmin !== undefined && !req.user.isAdmin)
|
|
913
|
+
// return [['isAdmin', 'forbidden']];
|
|
914
|
+
// return [];
|
|
915
|
+
// },
|
|
916
|
+
});
|
|
917
|
+
```
|
|
918
|
+
|
|
919
|
+
For privileged transitions (promote to admin, move across tenants) prefer a dedicated
|
|
920
|
+
endpoint with its own auth/audit, and keep the operation off the auto routes via `operations`.
|
|
921
|
+
|
|
897
922
|
### Full-text search filter
|
|
898
923
|
|
|
899
924
|
```typescript
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ All notable changes to this project are documented here. This project follows
|
|
|
7
7
|
|
|
8
8
|
Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)**.
|
|
9
9
|
|
|
10
|
+
## [0.1.10]
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Architecture Decision Records** in `docs/adr/` (shipped in the npm package so agents
|
|
15
|
+
consuming the library can read them): no-ORM/raw-SQL, open-by-default, non-transactional
|
|
16
|
+
bulk operations, always-updatable fields (no `excludeFromUpdate`), insert-pipeline
|
|
17
|
+
ordering, raw DB errors. Linked from README ("Design Decisions") and AGENTS.md.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **`excludeFromCreation` no longer strips values set by `beforeInsert`.** The exclusion list
|
|
22
|
+
was applied after the hook, so a server-generated value assigned in `beforeInsert` (the
|
|
23
|
+
documented pattern for TEXT primary keys, `createdAt`, audit columns) was silently removed
|
|
24
|
+
from the INSERT — a table with `excludeFromCreation: ['id']` and an id-generating hook
|
|
25
|
+
failed with a not-null violation. The client payload is now sanitized *before* the hook
|
|
26
|
+
runs, in both the single insert and the bulk upsert path: client-supplied values on
|
|
27
|
+
excluded fields are still ignored, hook-assigned ones reach the SQL. For the same reason,
|
|
28
|
+
secondary records now drop excluded fields *before* the engine's FK auto-fill, so listing
|
|
29
|
+
the FK column in a secondary table's `excludeFromCreation` no longer erases the injected
|
|
30
|
+
parent key.
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
- Clarified that `excludeFromCreation` is an ergonomics tool for creation, not a field-level
|
|
35
|
+
security mechanism: it deliberately does not apply to updates, where every Schema field is
|
|
36
|
+
writable by default. Field-level update rules (sensitive flags, roles, ownership/tenant
|
|
37
|
+
columns) are product logic to enforce via `beforeUpdate`/`validate` or dedicated endpoints —
|
|
38
|
+
see the new "Field-level update rules" section in the README and the matching pattern in
|
|
39
|
+
AGENTS_BACKEND.md.
|
|
40
|
+
|
|
10
41
|
## [0.1.7]
|
|
11
42
|
|
|
12
43
|
### Fixed
|
package/README.md
CHANGED
|
@@ -39,48 +39,67 @@ Point it at your database, and get a full REST API with search, advanced conditi
|
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
41
|
npm install fastify-auto-sqlapi fastify @fastify/postgres
|
|
42
|
+
# MySQL/MariaDB: npm install fastify-auto-sqlapi fastify mysql2
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
### 2. Create the config file
|
|
45
46
|
|
|
46
|
-
Create `sqlapi.config.ts` in your project root. This is used only by the
|
|
47
|
+
Create `sqlapi.config.ts` (or `.mjs`/`.js`) in your project root. This is used only by the
|
|
48
|
+
CLI generators, not at runtime.
|
|
47
49
|
|
|
48
50
|
```typescript
|
|
49
51
|
export default {
|
|
50
|
-
outputDir: './src/
|
|
51
|
-
schema: 'public', // PostgreSQL schema to introspect
|
|
52
|
+
outputDir: './src/db', // base dir for generated files (default: './src/db')
|
|
53
|
+
schema: 'public', // PostgreSQL schema to introspect (default: 'public')
|
|
54
|
+
// dialect: 'mysql', // 'postgres' (default) | 'mysql' | 'mariadb'
|
|
55
|
+
// envFile: '.env.local', // env file to load (default: '.env')
|
|
52
56
|
};
|
|
53
57
|
```
|
|
54
58
|
|
|
55
|
-
The DB connection is read from environment variables
|
|
59
|
+
The DB connection is read from environment variables (a `.env` file in the project root is
|
|
60
|
+
loaded automatically, without overriding variables already set):
|
|
56
61
|
|
|
57
62
|
```bash
|
|
58
63
|
# Either a full connection string:
|
|
59
|
-
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
|
|
64
|
+
DATABASE_URL=postgres://user:pass@localhost:5432/mydb # or mysql://...
|
|
60
65
|
|
|
61
|
-
# Or individual vars:
|
|
66
|
+
# Or individual vars (PostgreSQL):
|
|
62
67
|
POSTGRES_HOST=127.0.0.1
|
|
63
68
|
POSTGRES_PORT=5432
|
|
64
69
|
POSTGRES_USER=myuser
|
|
65
70
|
POSTGRES_PASSWORD=mypassword
|
|
66
71
|
POSTGRES_DB=mydb
|
|
72
|
+
|
|
73
|
+
# Or individual vars (MySQL/MariaDB):
|
|
74
|
+
MYSQL_HOST=127.0.0.1
|
|
75
|
+
MYSQL_PORT=3306
|
|
76
|
+
MYSQL_USER=myuser
|
|
77
|
+
MYSQL_PASSWORD=mypassword
|
|
78
|
+
MYSQL_DB=mydb
|
|
67
79
|
```
|
|
68
80
|
|
|
69
81
|
### 3. Generate schemas from your database
|
|
70
82
|
|
|
71
83
|
```bash
|
|
72
|
-
npx sqlapi-generate-schema
|
|
84
|
+
npx sqlapi-generate-schema # all tables
|
|
85
|
+
npx sqlapi-generate-schema --tables customer,order # only some
|
|
86
|
+
npx sqlapi-generate-schema --dialect mysql # override the config dialect
|
|
73
87
|
```
|
|
74
88
|
|
|
75
|
-
This introspects your tables and generates one TypeBox schema file per table
|
|
89
|
+
This introspects your tables and generates one TypeBox schema file per table in
|
|
90
|
+
`<outputDir>/schemas/` (e.g. `SchemaCustomer.ts`, `SchemaOrder.ts`). These files are
|
|
91
|
+
auto-generated — don't edit them; re-run the command after a DB change.
|
|
76
92
|
|
|
77
93
|
### 4. Generate the tables template
|
|
78
94
|
|
|
79
95
|
```bash
|
|
80
|
-
npx sqlapi-generate-tables
|
|
96
|
+
npx sqlapi-generate-tables --all # or: npx sqlapi-generate-tables customer order
|
|
81
97
|
```
|
|
82
98
|
|
|
83
|
-
This creates
|
|
99
|
+
This creates in `<outputDir>/tables/` one `Table*.ts` file per table — a `defineTable()`
|
|
100
|
+
call with auto-detected primary keys, foreign key relations, and all available options as
|
|
101
|
+
commented code — plus a `dbTables.ts` index. **These files are yours to customize**: the
|
|
102
|
+
generator never overwrites an existing file, so re-running it only adds files for new tables.
|
|
84
103
|
|
|
85
104
|
### 5. Register the plugin
|
|
86
105
|
|
|
@@ -88,7 +107,7 @@ This creates a `tables.ts` file with a `defineTable()` call for each table. It a
|
|
|
88
107
|
import Fastify from 'fastify';
|
|
89
108
|
import fastifyPostgres from '@fastify/postgres';
|
|
90
109
|
import { fastifyAutoSqlApi } from 'fastify-auto-sqlapi';
|
|
91
|
-
import { dbTables } from './src/
|
|
110
|
+
import { dbTables } from './src/db/tables/dbTables.js';
|
|
92
111
|
|
|
93
112
|
const app = Fastify();
|
|
94
113
|
|
|
@@ -156,7 +175,8 @@ const TableCustomer = defineTable({
|
|
|
156
175
|
|
|
157
176
|
// Ordering & filtering
|
|
158
177
|
defaultOrder: 'name', // default ORDER BY (camelCase fields map to DB columns)
|
|
159
|
-
excludeFromCreation: ['id'], //
|
|
178
|
+
excludeFromCreation: ['id'], // client-sent values ignored on INSERT (auto-increment,
|
|
179
|
+
// DB defaults); beforeInsert can still set them
|
|
160
180
|
readExclude: ['passwordHash'], // hide from all reads (writes unaffected)
|
|
161
181
|
distinctResults: true, // SELECT DISTINCT
|
|
162
182
|
|
|
@@ -269,7 +289,20 @@ await app.register(fastifyAutoSqlApi, {
|
|
|
269
289
|
| `maxBulkItems` | `number` | No | Max number of items accepted by the bulk endpoints (default: `1000`) |
|
|
270
290
|
| `debug` | `boolean` | No | Log all SQL queries to console |
|
|
271
291
|
|
|
272
|
-
|
|
292
|
+
The plugin picks up the connection from the Fastify instance: `fastify.pg` for PostgreSQL
|
|
293
|
+
(what `@fastify/postgres` decorates) or `fastify.mysql` for MySQL/MariaDB. For MySQL,
|
|
294
|
+
decorate a `mysql2/promise` pool (e.g. via `@fastify/mysql` with `promise: true`, or manually)
|
|
295
|
+
and pass `dialect: 'mysql'` or `'mariadb'`:
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
import mysql from 'mysql2/promise';
|
|
299
|
+
|
|
300
|
+
const pool = mysql.createPool({ host: '127.0.0.1', user: 'myuser', password: '...', database: 'mydb' });
|
|
301
|
+
app.decorate('mysql', pool);
|
|
302
|
+
app.addHook('onClose', async () => { await pool.end(); });
|
|
303
|
+
|
|
304
|
+
await app.register(fastifyAutoSqlApi, { DbTables: dbTables, dialect: 'mysql' });
|
|
305
|
+
```
|
|
273
306
|
|
|
274
307
|
## Security
|
|
275
308
|
|
|
@@ -333,6 +366,42 @@ only columns present in the generated Schema (as narrowed by `schemaOverrides` /
|
|
|
333
366
|
column — the Schema is the write whitelist. Trim the generated Schema (or use `excludeFromCreation`)
|
|
334
367
|
to keep sensitive columns out of it.
|
|
335
368
|
|
|
369
|
+
### Field-level update rules are product logic — use the hooks
|
|
370
|
+
|
|
371
|
+
`excludeFromCreation` is an **ergonomics tool for creation**, not a security mechanism: it
|
|
372
|
+
exists so auto-generated values (serial PKs, DB-default timestamps) don't have to be sent —
|
|
373
|
+
or validated — on insert. It deliberately does **not** apply to updates: every field in the
|
|
374
|
+
Schema is updatable by default, because whether a field may change is a product decision the
|
|
375
|
+
plugin cannot make for you (a super admin may legitimately fix an `updatedAt`; in one product
|
|
376
|
+
users may move themselves across tenants, in another nobody may).
|
|
377
|
+
|
|
378
|
+
So watch out for **sensitive flags and ownership fields** — `isAdmin`, `role`, tenant/owner
|
|
379
|
+
columns, state fields: if they are in the Schema, the auto-generated update route will accept
|
|
380
|
+
them. Encode your rules with the tools made for it:
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
// Silent strip: non-admins simply cannot touch the flag
|
|
384
|
+
beforeUpdate: async (db, req, fields) => {
|
|
385
|
+
if (!req.user.isAdmin) delete fields.isAdmin;
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
// Loud 400: touching the flag without permission is an error
|
|
389
|
+
validate: async (db, req, main) => {
|
|
390
|
+
if (main.isAdmin !== undefined && !req.user.isAdmin)
|
|
391
|
+
return [['isAdmin', 'forbidden']];
|
|
392
|
+
return [];
|
|
393
|
+
},
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
For privileged state transitions (promoting an admin, moving a record across tenants) consider
|
|
397
|
+
a dedicated endpoint with its own auth and audit instead of the auto-generated CRUD route —
|
|
398
|
+
`operations` lets you keep the sensitive operation off the auto routes entirely.
|
|
399
|
+
|
|
400
|
+
Note on tenants: when `tenantScope` is active, tenant-scoped callers can never move a record to
|
|
401
|
+
another tenant (the tenant column is enforced server-side); admin callers (`getTenantId` →
|
|
402
|
+
`null`) are unrestricted. That is the isolation contract of the opt-in tenancy feature, not a
|
|
403
|
+
field-level rule.
|
|
404
|
+
|
|
336
405
|
### Read visibility
|
|
337
406
|
|
|
338
407
|
`readExclude` hides columns from every read while leaving writes untouched — the case for a
|
|
@@ -491,7 +560,8 @@ Returns `{ main: { ... } }` or 404.
|
|
|
491
560
|
|
|
492
561
|
### DELETE /rest/{table}/:id
|
|
493
562
|
|
|
494
|
-
|
|
563
|
+
**Response (200):** `{ main: { <pk>: ... } }` (PK-only, like all write responses), or `404` if
|
|
564
|
+
the record does not exist.
|
|
495
565
|
|
|
496
566
|
### PUT /bulk/{table}
|
|
497
567
|
|
|
@@ -621,6 +691,14 @@ await app.register(async (instance) => {
|
|
|
621
691
|
}, { prefix: '/admin' });
|
|
622
692
|
```
|
|
623
693
|
|
|
694
|
+
## Design Decisions
|
|
695
|
+
|
|
696
|
+
Deliberate, non-obvious choices — open-by-default, non-transactional bulk operations,
|
|
697
|
+
always-updatable fields, raw DB errors, insert-pipeline ordering — are recorded as
|
|
698
|
+
[Architecture Decision Records in `docs/adr/`](./docs/adr/README.md), each with its
|
|
699
|
+
rationale and the alternatives that were rejected. Read them before filing an issue that
|
|
700
|
+
proposes changing one of these behaviors.
|
|
701
|
+
|
|
624
702
|
## Conventions
|
|
625
703
|
|
|
626
704
|
- **camelCase everywhere in the API** — requests, responses, `validate`, and all hooks (`beforeInsert`/`afterInsert`, `beforeUpdate`/`afterUpdate`, `beforeDelete`/`afterDelete`, `beforeBulkDelete`/`afterBulkDelete`) use schema field names
|
|
@@ -2,7 +2,20 @@ import type { FastifyRequest } from 'fastify';
|
|
|
2
2
|
import type { QueryClient } from '../db.js';
|
|
3
3
|
import type { ITable, DbTables, JoinDefinition } from '../../types.js';
|
|
4
4
|
export declare function findWriteJoin(tableConf: ITable, alias: string): JoinDefinition | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* Drop `excludeFromCreation` columns from a snake_case (DB-format) record.
|
|
7
|
+
*
|
|
8
|
+
* Exclusion is a whitelist on CLIENT input: call this on the client payload only,
|
|
9
|
+
* BEFORE any server-side value is assigned (beforeInsert mutations, FK auto-fill),
|
|
10
|
+
* so engine/hook-generated values on those fields reach the SQL.
|
|
11
|
+
*/
|
|
5
12
|
export declare function removeExcludedFields(record: Record<string, unknown>, tableConf: ITable): Record<string, unknown>;
|
|
13
|
+
/**
|
|
14
|
+
* Same whitelist as `removeExcludedFields`, but for a camelCase record (schema
|
|
15
|
+
* field names) and in place. Used on the client payload before the beforeInsert
|
|
16
|
+
* hook runs.
|
|
17
|
+
*/
|
|
18
|
+
export declare function removeExcludedFieldsCamel(record: Record<string, unknown>, tableConf: ITable): void;
|
|
6
19
|
export declare function findSecondaryTableConf(dbTables: DbTables, joinTableName: string): ITable | undefined;
|
|
7
20
|
interface PrepareCtx {
|
|
8
21
|
db: QueryClient;
|
|
@@ -12,10 +25,11 @@ interface PrepareCtx {
|
|
|
12
25
|
/**
|
|
13
26
|
* Prepare a single record for INSERT/UPSERT:
|
|
14
27
|
* 1. shallow camelCase copy of `input`
|
|
15
|
-
* 2. runValidation (camelCase)
|
|
16
|
-
* 3.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
28
|
+
* 2. runValidation (camelCase — sees the client payload as sent)
|
|
29
|
+
* 3. drop excludeFromCreation fields (client-input whitelist; applied BEFORE the
|
|
30
|
+
* hook so values it assigns — e.g. a server-generated id — reach the INSERT)
|
|
31
|
+
* 4. beforeInsert hook (caller can mutate the camelCase copy)
|
|
32
|
+
* 5. snakecaseRecord → DB format
|
|
19
33
|
*
|
|
20
34
|
* Returns both `camel` (input + hook mutations, used for FK auto-fill / afterInsert) and
|
|
21
35
|
* `snake` (DB-ready record). The original `input` is left intact.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"write-helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/write-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE5C,OAAO,KAAK,EACV,MAAM,EACN,QAAQ,EACR,cAAc,EAEf,MAAM,gBAAgB,CAAC;AAExB,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,cAAc,GAAG,SAAS,CAE5B;AAED,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQzB;AAED,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,GACpB,MAAM,GAAG,SAAS,CAKpB;AAED,UAAU,UAAU;IAClB,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,cAAc,CAAC;CACzB;AAED
|
|
1
|
+
{"version":3,"file":"write-helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/write-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE5C,OAAO,KAAK,EACV,MAAM,EACN,QAAQ,EACR,cAAc,EAEf,MAAM,gBAAgB,CAAC;AAExB,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,cAAc,GAAG,SAAS,CAE5B;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQzB;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAChB,IAAI,CAKN;AAED,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,GACpB,MAAM,GAAG,SAAS,CAKpB;AAED,UAAU,UAAU;IAClB,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,cAAc,CAAC;CACzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,EACvD,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,GACnC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,CAe7E;AAED,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,WAAW,EACf,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,QAAQ,EAClB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACrD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CA4DpD;AAED;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,WAAW,EACf,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACnD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CA+BpD"}
|
|
@@ -3,6 +3,13 @@ import { runValidation } from './validate.js';
|
|
|
3
3
|
export function findWriteJoin(tableConf, alias) {
|
|
4
4
|
return tableConf.allowedWriteJoins?.find((j) => j.alias === alias);
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* Drop `excludeFromCreation` columns from a snake_case (DB-format) record.
|
|
8
|
+
*
|
|
9
|
+
* Exclusion is a whitelist on CLIENT input: call this on the client payload only,
|
|
10
|
+
* BEFORE any server-side value is assigned (beforeInsert mutations, FK auto-fill),
|
|
11
|
+
* so engine/hook-generated values on those fields reach the SQL.
|
|
12
|
+
*/
|
|
6
13
|
export function removeExcludedFields(record, tableConf) {
|
|
7
14
|
if (!tableConf.excludeFromCreation?.length)
|
|
8
15
|
return record;
|
|
@@ -13,6 +20,18 @@ export function removeExcludedFields(record, tableConf) {
|
|
|
13
20
|
}
|
|
14
21
|
return result;
|
|
15
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Same whitelist as `removeExcludedFields`, but for a camelCase record (schema
|
|
25
|
+
* field names) and in place. Used on the client payload before the beforeInsert
|
|
26
|
+
* hook runs.
|
|
27
|
+
*/
|
|
28
|
+
export function removeExcludedFieldsCamel(record, tableConf) {
|
|
29
|
+
if (!tableConf.excludeFromCreation?.length)
|
|
30
|
+
return;
|
|
31
|
+
for (const field of tableConf.excludeFromCreation) {
|
|
32
|
+
delete record[field];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
16
35
|
export function findSecondaryTableConf(dbTables, joinTableName) {
|
|
17
36
|
for (const [, conf] of Object.entries(dbTables)) {
|
|
18
37
|
if (conf.Schema.tableName === joinTableName)
|
|
@@ -23,10 +42,11 @@ export function findSecondaryTableConf(dbTables, joinTableName) {
|
|
|
23
42
|
/**
|
|
24
43
|
* Prepare a single record for INSERT/UPSERT:
|
|
25
44
|
* 1. shallow camelCase copy of `input`
|
|
26
|
-
* 2. runValidation (camelCase)
|
|
27
|
-
* 3.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
45
|
+
* 2. runValidation (camelCase — sees the client payload as sent)
|
|
46
|
+
* 3. drop excludeFromCreation fields (client-input whitelist; applied BEFORE the
|
|
47
|
+
* hook so values it assigns — e.g. a server-generated id — reach the INSERT)
|
|
48
|
+
* 4. beforeInsert hook (caller can mutate the camelCase copy)
|
|
49
|
+
* 5. snakecaseRecord → DB format
|
|
30
50
|
*
|
|
31
51
|
* Returns both `camel` (input + hook mutations, used for FK auto-fill / afterInsert) and
|
|
32
52
|
* `snake` (DB-ready record). The original `input` is left intact.
|
|
@@ -39,11 +59,11 @@ export async function prepareInsertRecord(ctx, input, secondaries, options) {
|
|
|
39
59
|
if (!options?.skipValidate) {
|
|
40
60
|
await runValidation(ctx.db, ctx.request, ctx.tableConf, camel, secondaries);
|
|
41
61
|
}
|
|
62
|
+
removeExcludedFieldsCamel(camel, ctx.tableConf);
|
|
42
63
|
if (ctx.tableConf.beforeInsert) {
|
|
43
64
|
await ctx.tableConf.beforeInsert(ctx.db, ctx.request, camel);
|
|
44
65
|
}
|
|
45
|
-
|
|
46
|
-
snake = removeExcludedFields(snake, ctx.tableConf);
|
|
66
|
+
const snake = snakecaseRecord(camel, ctx.tableConf.Schema);
|
|
47
67
|
return { camel, snake };
|
|
48
68
|
}
|
|
49
69
|
export async function processSecondaries(db, tableConf, dbTables, mainRecord, secondaries) {
|
|
@@ -63,15 +83,16 @@ export async function processSecondaries(db, tableConf, dbTables, mainRecord, se
|
|
|
63
83
|
: joinSchema.col(secondaryPk);
|
|
64
84
|
const preparedRecords = records.map((rec) => {
|
|
65
85
|
let prepared = snakecaseRecord(rec, joinSchema);
|
|
86
|
+
// Remove excluded fields from the client payload BEFORE the FK auto-fill:
|
|
87
|
+
// an excluded FK column must not strip the engine-injected value below.
|
|
88
|
+
if (secondaryTableConf) {
|
|
89
|
+
prepared = removeExcludedFields(prepared, secondaryTableConf);
|
|
90
|
+
}
|
|
66
91
|
// Auto-fill FK from main record
|
|
67
92
|
const mainValue = Array.isArray(mainField)
|
|
68
93
|
? mainRecord[mainField[0]]
|
|
69
94
|
: mainRecord[mainField];
|
|
70
95
|
prepared[joinCol] = mainValue;
|
|
71
|
-
// Remove excluded fields
|
|
72
|
-
if (secondaryTableConf) {
|
|
73
|
-
prepared = removeExcludedFields(prepared, secondaryTableConf);
|
|
74
|
-
}
|
|
75
96
|
return prepared;
|
|
76
97
|
});
|
|
77
98
|
const secondaryUpsertKeys = tableConf.upsertMap?.get(joinSchema);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# 0001. No ORM — raw SQL with minimal dependencies
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-07-23 (foundational decision, predates this record)
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Auto-generating CRUD APIs from database tables is the territory of ORMs and heavy
|
|
9
|
+
API-builder frameworks. Adopting one would have provided models, migrations and query
|
|
10
|
+
building for free — at the price of a large dependency tree, an abstraction layer between
|
|
11
|
+
the consumer and their SQL, and lock-in to the ORM's schema model.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
The plugin talks to the database with raw SQL through thin driver adapters (`pg`,
|
|
16
|
+
`mysql2`), composing WHERE clauses with `node-condition-builder` and validating with
|
|
17
|
+
TypeBox. No ORM, no query-builder DSL, no migration system. Runtime dependencies are kept
|
|
18
|
+
to the minimum, and the plugin re-exports its building blocks (TypeBox, ConditionBuilder)
|
|
19
|
+
so consumers build on the same tools instead of adding parallel ones.
|
|
20
|
+
|
|
21
|
+
## Alternatives considered
|
|
22
|
+
|
|
23
|
+
- **Build on an ORM (Prisma, Drizzle, Sequelize, …)** — rejected: large dependency
|
|
24
|
+
surface, a second schema definition to keep in sync with the DB, and the ORM's
|
|
25
|
+
abstractions leak into the public API. The CLI introspection (`information_schema`)
|
|
26
|
+
gives the same "schema from the DB" ergonomics without the layer.
|
|
27
|
+
- **Hand-rolled query-builder DSL** — rejected: reinvents an ORM incrementally.
|
|
28
|
+
`node-condition-builder` covers the one genuinely composable part (conditions);
|
|
29
|
+
everything else is plain SQL the maintainer can read and debug.
|
|
30
|
+
|
|
31
|
+
## Consequences
|
|
32
|
+
|
|
33
|
+
- The engine owns dialect differences (placeholders, quoting, RETURNING vs insertId)
|
|
34
|
+
explicitly — see `src/lib/dialect.ts` and the adapters.
|
|
35
|
+
- Consumers get a framework approach: tools are provided (SqlApi, QueryClient,
|
|
36
|
+
ConditionBuilder), product structure is not imposed.
|
|
37
|
+
- Features an ORM would give for free (migrations, lazy relations, identity maps) are
|
|
38
|
+
intentionally out of scope.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# 0002. Open by default — no imposed auth model
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-07-23 (confirmed by the maintainer in June 2026)
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Registering the plugin with no configuration exposes every operation (read and write,
|
|
9
|
+
including bulk delete) on every table in `DbTables`. Security reviews repeatedly flag this
|
|
10
|
+
as a vulnerability and propose secure-by-default behavior: routes disabled until
|
|
11
|
+
explicitly enabled, or a mandatory auth hook.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
The plugin stays open by default. Authentication and authorization are the consumer's
|
|
16
|
+
job, wired through the tools provided for it: `onRequests` (global or per-table request
|
|
17
|
+
hooks), `operations` (per-table whitelist of registered routes), and `tenantScope`
|
|
18
|
+
(tenant isolation). The README carries a prominent warning in its Security section.
|
|
19
|
+
|
|
20
|
+
## Alternatives considered
|
|
21
|
+
|
|
22
|
+
- **Deny-by-default routes** (`operations` required, empty = nothing exposed) — rejected:
|
|
23
|
+
it turns the five-minute quick start into configuration boilerplate, and the plugin's
|
|
24
|
+
primary use case is rapid internal API scaffolding where the consumer adds auth at the
|
|
25
|
+
Fastify level anyway.
|
|
26
|
+
- **Mandatory auth callback** (refuse to register without an `onRequests`) — rejected:
|
|
27
|
+
imposes an auth model on consumers who legitimately handle auth outside the plugin
|
|
28
|
+
(gateway, network isolation, Fastify-level hooks on the parent scope).
|
|
29
|
+
|
|
30
|
+
## Consequences
|
|
31
|
+
|
|
32
|
+
- **You must lock it down before exposing it.** The responsibility is documented, not
|
|
33
|
+
enforced.
|
|
34
|
+
- Security reviews will keep flagging this; the answer is this ADR plus the README
|
|
35
|
+
Security section, not a behavior change.
|
|
36
|
+
- `operations` gates HTTP routes only — the programmatic `sqlApi.*` methods remain
|
|
37
|
+
available to consumer code, by design.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# 0003. Bulk operations are not transactional
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-07-23 (confirmed by the maintainer in June 2026)
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Single insert and update run inside a transaction: main record, secondaries, deletions
|
|
9
|
+
and after-hooks are atomic (`QueryClient.withTransaction`, degrading gracefully when the
|
|
10
|
+
adapter has no `connect()`). Bulk upsert and bulk delete do not — a failure partway
|
|
11
|
+
through leaves the earlier items committed. Reviews periodically report this as an
|
|
12
|
+
inconsistency and propose wrapping bulk operations in a transaction.
|
|
13
|
+
|
|
14
|
+
## Decision
|
|
15
|
+
|
|
16
|
+
Bulk operations stay non-transactional. They are a shortcut for issuing many single
|
|
17
|
+
operations efficiently, not an atomic batch primitive: "all or nothing" is explicitly not
|
|
18
|
+
part of their contract.
|
|
19
|
+
|
|
20
|
+
## Alternatives considered
|
|
21
|
+
|
|
22
|
+
- **Wrap each bulk request in one transaction** — rejected: changes the semantics from
|
|
23
|
+
"N independent operations" to "one atomic batch", which is a different feature. Large
|
|
24
|
+
batches would also hold long transactions with the associated lock footprint.
|
|
25
|
+
- **Opt-in `transactional: true` flag** — rejected for now: adds API surface for a need
|
|
26
|
+
no consumer has expressed; a consumer needing atomicity can call the programmatic
|
|
27
|
+
`sqlApi.*` methods inside their own `withTransaction`.
|
|
28
|
+
|
|
29
|
+
## Consequences
|
|
30
|
+
|
|
31
|
+
- A failed bulk request may have partially applied. Consumers that care must either
|
|
32
|
+
retry idempotently (upsert semantics make this natural) or manage their own
|
|
33
|
+
transaction via the programmatic API.
|
|
34
|
+
- Bulk delete reports the ids actually deleted (`afterBulkDelete` receives the actual
|
|
35
|
+
subset), which is the honest contract for a non-atomic batch.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# 0004. Updates are always open — no `excludeFromUpdate`
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-07-23
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
`excludeFromCreation` strips client-supplied fields from inserts. A symmetric
|
|
9
|
+
`excludeFromUpdate` was proposed to statically block fields on the update path (e.g.
|
|
10
|
+
`createdAt`, `isAdmin`), partly to close the asymmetry where a field excluded from
|
|
11
|
+
creation can still be modified via `PUT`.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
No `excludeFromUpdate`. Every Schema field is updatable by default, and
|
|
16
|
+
`excludeFromCreation` is defined as an **ergonomics tool for creation** (auto-generated
|
|
17
|
+
PKs, DB-default columns — its original purpose was handling autoincrement validation),
|
|
18
|
+
**not** a field-level security mechanism.
|
|
19
|
+
|
|
20
|
+
Field-level update rules are product logic the plugin must not decide: a super admin may
|
|
21
|
+
legitimately fix an `updatedAt`; in one product users may move themselves across tenants,
|
|
22
|
+
in another an admin moves others, in a third nobody moves anyone. A static list cannot
|
|
23
|
+
express any of this — real authorization is contextual (session, role, record state).
|
|
24
|
+
Consumers encode their rules in `beforeUpdate` (silent strip) or `validate` (loud 400),
|
|
25
|
+
or move privileged transitions to dedicated endpoints and keep them off the auto routes
|
|
26
|
+
via `operations`.
|
|
27
|
+
|
|
28
|
+
## Alternatives considered
|
|
29
|
+
|
|
30
|
+
- **`excludeFromUpdate` list** — rejected: the main risk is the *false sense of
|
|
31
|
+
security*. It only covers "nobody, ever, via API", while the dangerous real-world cases
|
|
32
|
+
(`isAdmin`, roles, ownership) are session-dependent — someone would use the list for
|
|
33
|
+
them and believe the problem solved.
|
|
34
|
+
- **`immutable: [...]` / per-field `writable` modes** — rejected: a bigger redesign of
|
|
35
|
+
the write model for the same limited static semantics.
|
|
36
|
+
|
|
37
|
+
## Consequences
|
|
38
|
+
|
|
39
|
+
- Sensitive flags in the Schema are updatable through the auto routes until the consumer
|
|
40
|
+
adds a hook. This is documented prominently (README Security → "Field-level update
|
|
41
|
+
rules are product logic", AGENTS_BACKEND.md pattern "Protect sensitive fields on
|
|
42
|
+
update").
|
|
43
|
+
- The tenant column is the one deliberate exception, and only under opt-in `tenantScope`:
|
|
44
|
+
tenant-scoped callers cannot re-tenant records (isolation contract), while admin
|
|
45
|
+
callers (`getTenantId` → `null`) remain unrestricted — consistent with "the plugin does
|
|
46
|
+
not decide product rules".
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# 0005. Insert pipeline: client payload sanitized before `beforeInsert`
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-07-23
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Until v0.1.9 the insert pipeline applied `excludeFromCreation` *after* the `beforeInsert`
|
|
9
|
+
hook, so a value the hook assigned to an excluded field was silently stripped from the
|
|
10
|
+
INSERT. This broke the documented server-generated-id pattern (TEXT PK in
|
|
11
|
+
`excludeFromCreation`, id generated in `beforeInsert` → not-null violation) and
|
|
12
|
+
contradicted the documented contract that hook mutations propagate to the SQL. The same
|
|
13
|
+
ordering flaw let an excluded FK column erase the engine's own FK auto-fill on secondary
|
|
14
|
+
records.
|
|
15
|
+
|
|
16
|
+
## Decision
|
|
17
|
+
|
|
18
|
+
Exclusion is a whitelist on **client input** and runs at the client boundary. The insert
|
|
19
|
+
pipeline order is, for both single insert and bulk upsert:
|
|
20
|
+
|
|
21
|
+
1. `validate` — receives the payload **as sent by the client** (documented contract);
|
|
22
|
+
2. `excludeFromCreation` strip;
|
|
23
|
+
3. `beforeInsert` — hook mutations, including on excluded fields, reach the SQL;
|
|
24
|
+
4. conversion to DB column format.
|
|
25
|
+
|
|
26
|
+
For secondary records, exclusion runs **before** the engine's FK auto-fill, so the
|
|
27
|
+
injected parent key always survives.
|
|
28
|
+
|
|
29
|
+
Server-side writers are ordered by trust: client input is sanitized first, then hooks
|
|
30
|
+
mutate freely, then engine-enforced values (tenant injection/validation) run last and
|
|
31
|
+
fail loudly on conflict rather than stripping silently.
|
|
32
|
+
|
|
33
|
+
## Alternatives considered
|
|
34
|
+
|
|
35
|
+
- **Strip after the hook** (previous behavior) — rejected: silently discards
|
|
36
|
+
server-generated values; the only thing it "protected" was the ability of a hook to
|
|
37
|
+
accidentally write an excluded field, which is server code and trusted.
|
|
38
|
+
- **Strip before `validate` too** — rejected: `validate` is documented to receive the
|
|
39
|
+
record as sent by the client; changing its input would break that contract for no
|
|
40
|
+
gain.
|
|
41
|
+
|
|
42
|
+
## Consequences
|
|
43
|
+
|
|
44
|
+
- The pattern `excludeFromCreation: ['id']` + id-generating `beforeInsert` works as
|
|
45
|
+
documented; consumers no longer need the `schemaOverrides` workaround.
|
|
46
|
+
- Behavior change (noted in the CHANGELOG): a hook that assigns an excluded field now
|
|
47
|
+
writes it, where it was previously discarded.
|
|
48
|
+
- `afterInsert` no longer sees client-supplied values on excluded fields — it sees what
|
|
49
|
+
was actually written.
|
|
50
|
+
- Locked by unit tests (single, bulk, upsert, secondaries, validate-ordering) and by a
|
|
51
|
+
dialect-parametric integration suite reproducing the original failure.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# 0006. Raw DB errors surface as 500 — no SQLSTATE→HTTP mapping
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-07-23 (confirmed by the maintainer in June 2026)
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Constraint violations (unique, foreign key, not-null) bubble up from the driver and reach
|
|
9
|
+
the client as a `500` carrying the raw database message. A review proposed mapping
|
|
10
|
+
SQLSTATE codes to HTTP semantics — unique violation → `409`, FK/not-null violation →
|
|
11
|
+
`400` — with sanitized messages.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
No SQLSTATE mapping in the engine. Errors the plugin itself detects (validation, missing
|
|
16
|
+
PK, not found, tenant violations) throw proper `4xx` via `httpError`; everything coming
|
|
17
|
+
from the database is passed through unmapped.
|
|
18
|
+
|
|
19
|
+
## Alternatives considered
|
|
20
|
+
|
|
21
|
+
- **SQLSTATE→HTTP mapping layer** — rejected: the mapping is dialect-specific
|
|
22
|
+
(PostgreSQL SQLSTATE vs MySQL errno), inevitably partial, and encodes product decisions
|
|
23
|
+
(is a duplicate a conflict to surface or an internal bug?) the plugin should not make.
|
|
24
|
+
- **Sanitizing DB messages** — rejected as a plugin default: hiding details is an
|
|
25
|
+
environment policy, and Fastify already gives consumers the hook for it.
|
|
26
|
+
|
|
27
|
+
## Consequences
|
|
28
|
+
|
|
29
|
+
- Constraint violations look like server errors (`500`) to API clients, and driver
|
|
30
|
+
messages may leak schema details (table/column/constraint names). Consumers who care
|
|
31
|
+
set a Fastify `setErrorHandler` — one place, their policy, their status codes.
|
|
32
|
+
- A consumer-facing "friendly duplicate error" is expected to be handled in `validate`
|
|
33
|
+
(explicit pre-check) or in the consumer's error handler, not by the engine guessing
|
|
34
|
+
intent from SQLSTATE.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Architecture Decision Records
|
|
2
|
+
|
|
3
|
+
Deliberate design decisions of `fastify-auto-sqlapi`, with their rationale and the
|
|
4
|
+
alternatives that were considered and rejected.
|
|
5
|
+
|
|
6
|
+
**If you are an agent or a contributor about to "fix" one of the behaviors below: read the
|
|
7
|
+
matching ADR first.** These are not gaps or oversights — proposals that re-litigate a
|
|
8
|
+
decision recorded here should either reference new arguments the ADR does not address, or
|
|
9
|
+
be dropped.
|
|
10
|
+
|
|
11
|
+
ADRs are immutable: a decision is changed by adding a new ADR that supersedes the old one,
|
|
12
|
+
not by editing history.
|
|
13
|
+
|
|
14
|
+
## Index
|
|
15
|
+
|
|
16
|
+
| # | Decision |
|
|
17
|
+
|---|----------|
|
|
18
|
+
| [0001](./0001-no-orm-raw-sql.md) | No ORM — raw SQL with minimal dependencies |
|
|
19
|
+
| [0002](./0002-open-by-default.md) | Open by default — no imposed auth model |
|
|
20
|
+
| [0003](./0003-bulk-not-transactional.md) | Bulk operations are not transactional |
|
|
21
|
+
| [0004](./0004-updates-always-open.md) | Updates are always open — no `excludeFromUpdate` |
|
|
22
|
+
| [0005](./0005-insert-pipeline-sanitize-before-hooks.md) | Insert pipeline: client payload sanitized before `beforeInsert` |
|
|
23
|
+
| [0006](./0006-raw-db-errors.md) | Raw DB errors surface as 500 — no SQLSTATE→HTTP mapping |
|
|
24
|
+
|
|
25
|
+
## When to write one
|
|
26
|
+
|
|
27
|
+
Only when a decision was genuinely debated, or is counter-intuitive enough that a reasonable
|
|
28
|
+
outsider would mistake it for a bug. Plain conventions belong in [AGENTS.md](../../AGENTS.md).
|
|
29
|
+
|
|
30
|
+
## Format
|
|
31
|
+
|
|
32
|
+
```markdown
|
|
33
|
+
# NNNN. Title (imperative statement of the decision)
|
|
34
|
+
|
|
35
|
+
- **Status**: accepted | superseded by NNNN
|
|
36
|
+
- **Date**: YYYY-MM-DD
|
|
37
|
+
|
|
38
|
+
## Context
|
|
39
|
+
What problem or tension forced a choice.
|
|
40
|
+
|
|
41
|
+
## Decision
|
|
42
|
+
What was decided, stated plainly.
|
|
43
|
+
|
|
44
|
+
## Alternatives considered
|
|
45
|
+
Each rejected option and the reason it lost.
|
|
46
|
+
|
|
47
|
+
## Consequences
|
|
48
|
+
What follows — including the costs accepted knowingly.
|
|
49
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastify-auto-sqlapi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Fastify plugin for automatic CRUD API generation from PostgreSQL, MySQL and MariaDB tables",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
20
|
"dist",
|
|
21
|
+
"docs",
|
|
21
22
|
"AGENTS.md",
|
|
22
23
|
"AGENTS_BACKEND.md",
|
|
23
24
|
"AGENTS_FRONTEND.md",
|