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 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 CLI generators, not at runtime.
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/schemas', // where to generate Schema files
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 (e.g. `SchemaCustomer.ts`, `SchemaOrder.ts`). These files are auto-generated — don't edit them.
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 a `tables.ts` file with a `defineTable()` call for each table. It auto-detects primary keys, foreign key relations, and lays out all available options as commented code. **This file is yours to customize.**
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/schemas/tables.js';
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'], // omit from INSERT (e.g. auto-increment)
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
- For MySQL/MariaDB, register `mysql2/promise` pool instead of `@fastify/postgres` and pass `dialect: 'mysql'` or `'mariadb'`.
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
- Returns the deleted record or 404.
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. beforeInsert hook (caller can mutate the camelCase copy)
17
- * 4. snakecaseRecord → DB format
18
- * 5. removeExcludedFields
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;;;;;;;;;;;;;GAaG;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,CA2DpD;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"}
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. beforeInsert hook (caller can mutate the camelCase copy)
28
- * 4. snakecaseRecord → DB format
29
- * 5. removeExcludedFields
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
- let snake = snakecaseRecord(camel, ctx.tableConf.Schema);
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.9",
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",