fastify-auto-sqlapi 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/AGENTS_BACKEND.md +41 -2
  2. package/AGENTS_FRONTEND.md +7 -5
  3. package/README.md +105 -5
  4. package/dist/bin/generate-schema.js +2 -2
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/lib/adapters/mysql-adapter.d.ts +4 -0
  8. package/dist/lib/adapters/mysql-adapter.d.ts.map +1 -1
  9. package/dist/lib/adapters/mysql-adapter.js +28 -17
  10. package/dist/lib/adapters/pg-adapter.d.ts +4 -0
  11. package/dist/lib/adapters/pg-adapter.d.ts.map +1 -1
  12. package/dist/lib/adapters/pg-adapter.js +18 -7
  13. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  14. package/dist/lib/cli/mysql-introspect.js +3 -1
  15. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  16. package/dist/lib/cli/pg-introspect.js +13 -4
  17. package/dist/lib/cli/schema-codegen.d.ts +2 -1
  18. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  19. package/dist/lib/cli/schema-codegen.js +17 -3
  20. package/dist/lib/cli/tables-codegen.d.ts +2 -0
  21. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  22. package/dist/lib/cli/tables-codegen.js +43 -12
  23. package/dist/lib/db.d.ts +17 -0
  24. package/dist/lib/db.d.ts.map +1 -1
  25. package/dist/lib/db.js +47 -0
  26. package/dist/lib/engine/bulk/bulk-delete.d.ts.map +1 -1
  27. package/dist/lib/engine/bulk/bulk-delete.js +25 -8
  28. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  29. package/dist/lib/engine/bulk/bulk-upsert.js +12 -9
  30. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  31. package/dist/lib/engine/rest/delete.js +9 -5
  32. package/dist/lib/engine/rest/get.js +1 -1
  33. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  34. package/dist/lib/engine/rest/insert.js +41 -34
  35. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  36. package/dist/lib/engine/rest/update.js +74 -57
  37. package/dist/lib/engine/search/search.d.ts.map +1 -1
  38. package/dist/lib/engine/search/search.js +94 -34
  39. package/dist/lib/schema/bulk-delete.d.ts +1 -1
  40. package/dist/lib/schema/bulk-delete.d.ts.map +1 -1
  41. package/dist/lib/schema/bulk-delete.js +2 -2
  42. package/dist/lib/schema/bulk-upsert.d.ts +1 -1
  43. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  44. package/dist/lib/schema/bulk-upsert.js +5 -3
  45. package/dist/lib/schema/helpers.d.ts +2 -0
  46. package/dist/lib/schema/helpers.d.ts.map +1 -1
  47. package/dist/lib/schema/helpers.js +3 -2
  48. package/dist/lib/schema/insert.d.ts.map +1 -1
  49. package/dist/lib/schema/insert.js +4 -1
  50. package/dist/lib/schema/update.d.ts.map +1 -1
  51. package/dist/lib/schema/update.js +4 -1
  52. package/dist/lib/sql-api.d.ts +2 -0
  53. package/dist/lib/sql-api.d.ts.map +1 -1
  54. package/dist/lib/sql-api.js +1 -0
  55. package/dist/lib/table-helpers.d.ts.map +1 -1
  56. package/dist/lib/table-helpers.js +10 -9
  57. package/dist/lib/tenant.d.ts +24 -2
  58. package/dist/lib/tenant.d.ts.map +1 -1
  59. package/dist/lib/tenant.js +0 -0
  60. package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
  61. package/dist/routes/auto/bulk-delete.routes.js +3 -1
  62. package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
  63. package/dist/routes/auto/bulk-upsert.routes.js +3 -1
  64. package/dist/routes/auto/delete.routes.d.ts.map +1 -1
  65. package/dist/routes/auto/delete.routes.js +1 -0
  66. package/dist/routes/auto/get.routes.d.ts.map +1 -1
  67. package/dist/routes/auto/get.routes.js +1 -0
  68. package/dist/routes/auto/insert.routes.d.ts.map +1 -1
  69. package/dist/routes/auto/insert.routes.js +1 -0
  70. package/dist/routes/auto/route-helpers.d.ts +3 -1
  71. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  72. package/dist/routes/auto/route-helpers.js +3 -0
  73. package/dist/routes/auto/search.routes.d.ts.map +1 -1
  74. package/dist/routes/auto/search.routes.js +9 -0
  75. package/dist/routes/auto/update.routes.d.ts.map +1 -1
  76. package/dist/routes/auto/update.routes.js +1 -0
  77. package/dist/types/cli.d.ts +6 -0
  78. package/dist/types/cli.d.ts.map +1 -1
  79. package/dist/types/db.d.ts +11 -0
  80. package/dist/types/db.d.ts.map +1 -1
  81. package/dist/types/plugin.d.ts +16 -0
  82. package/dist/types/plugin.d.ts.map +1 -1
  83. package/dist/types/plugin.js +4 -1
  84. package/dist/types/schema.d.ts +2 -0
  85. package/dist/types/schema.d.ts.map +1 -1
  86. package/dist/types/search.d.ts +7 -0
  87. package/dist/types/search.d.ts.map +1 -1
  88. package/dist/types/table.d.ts +40 -2
  89. package/dist/types/table.d.ts.map +1 -1
  90. package/package.json +2 -2
package/AGENTS_BACKEND.md CHANGED
@@ -126,6 +126,8 @@ await app.register(fastifyAutoSqlApi, {
126
126
  prefix: '/auto', // optional — standard Fastify prefix
127
127
  onRequests: [], // optional — global auth hooks (run on every route)
128
128
  getTenantId: (request) => request.user?.organizationId ?? null, // optional — multi-tenant
129
+ maxItemsPerPage: 1000, // optional — cap on search page size / no-paginator LIMIT (default 1000)
130
+ maxBulkItems: 1000, // optional — cap on bulk array length (default 1000)
129
131
  debug: true, // optional — log all SQL queries and params to console
130
132
  });
131
133
 
@@ -305,6 +307,25 @@ export const TableCustomer = defineTable({
305
307
  // Mutate fields before UPDATE. camelCase. PK is present for reference but excluded from UPDATE SET.
306
308
  fields.updatedBy = req.user.id;
307
309
  },
310
+ afterUpdate: async (db, req, record, secondaryRecords, deletionRecords) => {
311
+ // Called after UPDATE + secondaries + deletions, INSIDE the write transaction: throwing rolls back.
312
+ },
313
+ beforeDelete: async (db, req, id) => {
314
+ // Throw to abort the single delete. For tenant-scoped tables runs only after ownership is verified.
315
+ },
316
+ afterDelete: async (db, req, id) => {
317
+ // Called after a successful single delete (not on 404).
318
+ },
319
+ beforeBulkDelete: async (db, req, ids) => {
320
+ // Called ONCE with all ids before a bulk delete. Throw to abort the whole batch.
321
+ },
322
+ afterBulkDelete: async (db, req, deletedIds) => {
323
+ // Called ONCE with the ids ACTUALLY deleted (may be a subset of the requested ids).
324
+ },
325
+
326
+ // OPERATIONS — whitelist of auto-generated HTTP routes for this table.
327
+ // Omitted = ALL operations exposed (default-open!). Does not affect programmatic sqlApi.*.
328
+ // operations: ['search', 'get'],
308
329
 
309
330
  // AUTH — per-table request hooks
310
331
  onRequests: [
@@ -340,6 +361,21 @@ buildRelation(mainSchema, mainField, joinSchema, joinField, options?)
340
361
 
341
362
  **Choosing `unique`**: if `joinField` is the PK (or part of the composite PK) of `joinSchema`, the relation is N:1 — you almost certainly want `unique: true` so the alias is usable in `joinLeft`.
342
363
 
364
+ **Owned child tables (translations, `*_info` details): use a writeJoin, not a standalone table.** A table that only exists as a child of a parent — e.g. `product_info` with composite PK `(product_id, lang)` — should be an `allowedWriteJoins` on the parent, not its own `DbTables` entry. The engine auto-fills the FK (`product_id`); add it to `upsertMap` (conflict key = the composite PK) to upsert children passing only their own fields. Avoids redundant endpoints/validators.
365
+
366
+ ```typescript
367
+ // on the parent (product) table:
368
+ allowedWriteJoins: [
369
+ buildRelation(SchemaProduct, 'id', SchemaProductInfo, 'productId', { alias: 'translations' }),
370
+ ],
371
+ upsertMap: buildUpsertRules(
372
+ buildUpsertRule(SchemaProductInfo, ['productId', 'lang']), // composite conflict key
373
+ ),
374
+ // → PUT /rest/product { "main": {...}, "secondaries": { "translations": [{ "lang": "en", "name": "Bike" }] } }
375
+ ```
376
+
377
+ Expose a composite-PK table as a standalone CRUD table only when it stands on its own (M:N link tables, natural keys) — composite PKs are fully supported there too.
378
+
343
379
  **Aliasing the same table twice**: declare two `buildRelation` entries with different `alias`. Example: a `session` table referencing `user` for both `createdBy` and `updatedBy`:
344
380
 
345
381
  ```typescript
@@ -664,6 +700,7 @@ Behavior:
664
700
  - **Insert**: auto-injects `organization_id` if single tenant; validates if already present (403 if mismatch); 400 if multi-tenant without explicit value
665
701
  - **Update**: strips `organization_id` from SET (can't change tenant), adds to WHERE condition
666
702
  - **Bulk upsert**: auto-injects on all records
703
+ - **Upsert conflict guard** (`upsertMap` set): before the `ON CONFLICT DO UPDATE`, a probe rejects (403) any incoming conflict key that matches a row owned by another tenant — an upsert cannot overwrite or re-assign a foreign tenant's row.
667
704
 
668
705
  ### Indirect tenant (via JOIN to parent table)
669
706
 
@@ -685,7 +722,7 @@ const TableOrder = defineTable({
685
722
  Behavior:
686
723
  - **Read**: INNER JOIN to parent table + WHERE on parent's tenant column
687
724
  - **Insert/Bulk upsert**: validates FK references belong to tenant (1 batch query)
688
- - **Update**: pre-check via SELECT with INNER JOIN (404 if not found)
725
+ - **Update**: pre-check via SELECT with INNER JOIN (404 if not found). If the payload also changes the through-FK (`localField`), the new value is re-validated against the tenant — moving a record into another tenant's scope is rejected (403).
689
726
  - **Delete**: subquery `DELETE WHERE pk IN (SELECT ... INNER JOIN ... WHERE tenant IN (...))`
690
727
 
691
728
  ### Multi-tenant users
@@ -724,8 +761,10 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
724
761
  - **`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.
725
762
  - **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.
726
763
  - **`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.
727
- - **All hooks and validators receive camelCase records**: `validate`, `beforeInsert`, `beforeUpdate`, and `afterInsert` all 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.
764
+ - **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.
728
765
  - **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 } } })`.
766
+ - **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.
767
+ - **Request limits**: `maxItemsPerPage` (default 1000) caps the search page size and is applied as the `LIMIT` even when no paginator is sent (so an empty-body search can't dump a whole table); over-limit `itemsPerPage` → 400. `maxBulkItems` (default 1000) caps the bulk array length via schema `maxItems` → 400. Programmatic `sqlApi.*` calls are uncapped.
729
768
  - **Tenant filtering**: when `tenantScope` is set on a table and `getTenantId` is provided in plugin options, all CRUD operations are automatically scoped to the tenant. `getTenantId` returning `null`/`undefined` = admin (no filter). Returning an array = multi-tenant user (IN clause).
730
769
 
731
770
  ---
@@ -35,10 +35,10 @@ Without prefix, routes are at root: `/search/customer`, `/rest/customer/:id`, et
35
35
  ```
36
36
 
37
37
  - `orderBy` — SQL ORDER BY clause (default: `defaultOrder` from table config). Supports dotted notations for joins, see below.
38
- - `page` + `itemsPerPage` — pagination (when either is present, response includes `pagination` object; `page` defaults to 1, `itemsPerPage` defaults to 500)
38
+ - `page` + `itemsPerPage` — pagination (when either is present, response includes `pagination` object; `page` defaults to 1, `itemsPerPage` defaults to 500). `itemsPerPage` is capped server-side at `maxItemsPerPage` (default 1000); sending a larger value returns 400.
39
39
  - `computeMin`, `computeMax`, `computeSum`, `computeAvg` — aggregate a column, returned in `pagination.computed`
40
40
 
41
- ### Body (all fields optional, empty `{}` returns all records)
41
+ ### Body (all fields optional, empty `{}` returns up to `maxItemsPerPage` records)
42
42
 
43
43
  ```json
44
44
  {
@@ -350,7 +350,7 @@ Add `page` or `itemsPerPage` to the query string to enable pagination:
350
350
  ?itemsPerPage=10 # page defaults to 1
351
351
  ```
352
352
 
353
- When either is present, the response includes a `pagination` object with total count, total pages, and the paginator values. `page` defaults to 1, `itemsPerPage` defaults to 500.
353
+ When either is present, the response includes a `pagination` object with total count, total pages, and the paginator values. `page` defaults to 1, `itemsPerPage` defaults to 500. `itemsPerPage` is capped at `maxItemsPerPage` (default 1000) — a larger value returns 400. Even without pagination, a search returns at most `maxItemsPerPage` rows.
354
354
 
355
355
  ### Computed values (aggregations on main table)
356
356
 
@@ -503,6 +503,8 @@ Insert or update multiple records in a single request.
503
503
 
504
504
  All main records are inserted/upserted in a single SQL query. Secondaries and deletions are processed per-item. `secondaries`/`deletions` keys are **aliases**.
505
505
 
506
+ The array is capped at `maxBulkItems` (default 1000); a longer array returns 400 — chunk client-side if you have more.
507
+
506
508
  **Response (200):** Array of `{ main (PK-only), secondaries? (PK-only), deletions? }`.
507
509
 
508
510
  ---
@@ -517,7 +519,7 @@ Delete multiple records by PK.
517
519
  [{ "id": 1 }, { "id": 2 }, { "id": 3 }]
518
520
  ```
519
521
 
520
- Each object must contain the PK field. All deletions execute as a single `DELETE WHERE pk IN (...)`.
522
+ Each object must contain the PK field. All deletions execute as a single `DELETE WHERE pk IN (...)`. The array is capped at `maxBulkItems` (default 1000) — a longer array returns 400.
521
523
 
522
524
  **Response (200):** Array of PK-only objects.
523
525
 
@@ -531,7 +533,7 @@ Each object must contain the PK field. All deletions execute as a single `DELETE
531
533
 
532
534
  Insert, update, and bulk-upsert endpoints may return **structured field-level validation errors** (400). Two sources:
533
535
 
534
- 1. **Schema validation** (TypeBox/Ajv) — type mismatches, missing required fields
536
+ 1. **Schema validation** (TypeBox/Ajv) — type mismatches, missing required fields, and **unknown properties** (write bodies reject any field not in the table Schema — send only real columns, don't include extra keys)
535
537
  2. **Custom validation** (`validate` / `validateBulk` on the table) — business rules, cross-entity checks
536
538
 
537
539
  Both return the same response format:
package/README.md CHANGED
@@ -14,7 +14,8 @@ Point it at your database, and get a full REST API with search, advanced conditi
14
14
  - **Bulk operations** — batch insert/upsert/delete in single queries
15
15
  - **Multi-tenant** — automatic row-level isolation, zero code in handlers
16
16
  - **Validation** — structured field-level validation with cross-entity support
17
- - **Hooks** — `beforeInsert`, `beforeUpdate`, `afterInsert` for custom logic
17
+ - **Hooks** — full before/after matrix (insert, update, delete, bulk delete) for custom logic
18
+ - **Transactions** — insert/update with secondaries run atomically (rollback on failure)
18
19
  - **Swagger UI** — optional, auto-configured from your schemas
19
20
  - **Composable** — register all routes or pick only what you need
20
21
 
@@ -183,10 +184,16 @@ const TableCustomer = defineTable({
183
184
  return [];
184
185
  },
185
186
 
186
- // Hooks (runs after validation) — all receive camelCase records (schema field names)
187
+ // Hooks (run after validation) — all receive camelCase records (schema field names).
188
+ // after* hooks for insert/update run INSIDE the write transaction: throwing rolls back.
187
189
  beforeInsert: async (db, req, record) => { /* camelCase; mutations propagate to INSERT */ },
188
- beforeUpdate: async (db, req, fields) => { /* camelCase; PK included for reference, excluded from UPDATE SET */ },
189
190
  afterInsert: async (db, req, record, secondaryRecords) => { /* camelCase; input merged with generated PK */ },
191
+ beforeUpdate: async (db, req, fields) => { /* camelCase; PK included for reference, excluded from UPDATE SET */ },
192
+ afterUpdate: async (db, req, record, secondaryRecords, deletionRecords) => { /* after UPDATE + secondaries + deletions */ },
193
+ beforeDelete: async (db, req, id) => { /* throw to abort the deletion */ },
194
+ afterDelete: async (db, req, id) => { /* after a successful single delete */ },
195
+ beforeBulkDelete: async (db, req, ids) => { /* called ONCE with all ids; throw to abort the batch */ },
196
+ afterBulkDelete: async (db, req, deletedIds) => { /* called ONCE with the ids ACTUALLY deleted */ },
190
197
 
191
198
  // Auth (per-table)
192
199
  onRequests: [
@@ -231,6 +238,8 @@ await app.register(fastifyAutoSqlApi, {
231
238
  swagger: true, // Optional — enable Swagger UI (or pass SwaggerOptions)
232
239
  onRequests: [authMiddleware], // Optional — global hooks applied to every route
233
240
  getTenantId: (req) => id, // Optional — multi-tenant function
241
+ maxItemsPerPage: 1000, // Optional — cap on search page size (default: 1000)
242
+ maxBulkItems: 1000, // Optional — cap on bulk array length (default: 1000)
234
243
  debug: true, // Optional — log all SQL queries and params
235
244
  });
236
245
  ```
@@ -243,10 +252,74 @@ await app.register(fastifyAutoSqlApi, {
243
252
  | `swagger` | `boolean \| SwaggerOptions` | No | Enable Swagger UI |
244
253
  | `onRequests` | `Function[]` | No | Global auth/middleware hooks |
245
254
  | `getTenantId` | `(req) => id \| null` | No | Tenant resolver for multi-tenant |
255
+ | `maxItemsPerPage` | `number` | No | Max search page size, and the row `LIMIT` applied when no paginator is sent (default: `1000`) |
256
+ | `maxBulkItems` | `number` | No | Max number of items accepted by the bulk endpoints (default: `1000`) |
246
257
  | `debug` | `boolean` | No | Log all SQL queries to console |
247
258
 
248
259
  For MySQL/MariaDB, register `mysql2/promise` pool instead of `@fastify/postgres` and pass `dialect: 'mysql'` or `'mariadb'`.
249
260
 
261
+ ## Security
262
+
263
+ > ⚠️ **The plugin is open by default.** Registering it without any configuration exposes
264
+ > **all operations on all tables in `DbTables`** (read AND write, including bulk delete) to
265
+ > anyone who can reach the server. This is intentional — the plugin provides the tools and
266
+ > does not impose an auth model — but it means **you** are responsible for locking it down
267
+ > before exposing it.
268
+
269
+ Three layers are available, combinable:
270
+
271
+ **1. Authentication / authorization hooks** — `onRequests` runs before every auto-generated
272
+ route (globally or per table):
273
+
274
+ ```typescript
275
+ await app.register(fastifyAutoSqlApi, {
276
+ DbTables: dbTables,
277
+ onRequests: [async (request, reply) => {
278
+ await request.jwtVerify(); // or any auth check; throw/reply to block
279
+ }],
280
+ });
281
+ ```
282
+
283
+ **2. Per-table operation whitelist** — `operations` limits which routes are registered for a
284
+ table. Omitted = all operations (default). Unlisted operations are not registered at all
285
+ (they answer 404):
286
+
287
+ ```typescript
288
+ const TableAuditLog = defineTable({
289
+ primary: 'id',
290
+ ...exportTableInfo(SchemaAuditLog),
291
+ operations: ['search', 'get'], // read-only over HTTP: no insert/update/delete/bulk
292
+ });
293
+ ```
294
+
295
+ Note: `operations` gates the HTTP routes only. The programmatic `fastify.sqlApi.*` methods
296
+ are always available to your own code.
297
+
298
+ **3. Multi-tenancy** — `getTenantId` + `tenantScope` filter every query by tenant (see
299
+ [Multi-Tenant](#multi-tenant)).
300
+
301
+ ### Request limits
302
+
303
+ Two hard caps bound how much work a single request can trigger (defense against accidental or
304
+ malicious resource exhaustion — relevant precisely because the API is open by default):
305
+
306
+ - **Search result size** — `itemsPerPage` above `maxItemsPerPage` (default `1000`) is rejected
307
+ with `400`. When a search omits pagination entirely, the same value is applied as a `LIMIT`, so
308
+ an empty-body `POST /search/:table` can never dump an entire table.
309
+ - **Bulk array size** — the `PUT /bulk/:table` and `POST /bulk/:table/delete` bodies reject arrays
310
+ longer than `maxBulkItems` (default `1000`) at schema validation.
311
+
312
+ Both are configurable in the plugin options. The programmatic `fastify.sqlApi.*` methods are not
313
+ capped — they run your own trusted code.
314
+
315
+ ### Write whitelist
316
+
317
+ Insert/update/bulk-upsert bodies reject **unknown properties** (`additionalProperties: false`):
318
+ only columns present in the generated Schema (as narrowed by `schemaOverrides` /
319
+ `excludeFromCreation`) can be written. An unexpected field is a `400`, never a silently-written
320
+ column — the Schema is the write whitelist. Trim the generated Schema (or use `excludeFromCreation`)
321
+ to keep sensitive columns out of it.
322
+
250
323
  ## API Reference
251
324
 
252
325
  ### POST /search/{table}
@@ -311,6 +384,8 @@ Search with filters, advanced conditions, pagination, ordering, joins, and aggre
311
384
 
312
385
  **Querystring** (optional): `orderBy`, `page`, `itemsPerPage`, `computeMin`, `computeMax`, `computeSum`, `computeAvg`
313
386
 
387
+ > `itemsPerPage` is capped at `maxItemsPerPage` (default `1000`); a larger value returns `400`. A search with no `page`/`itemsPerPage` returns at most that many rows (no full-table dumps). See [Request limits](#request-limits).
388
+
314
389
  **Response:**
315
390
 
316
391
  ```json
@@ -356,6 +431,20 @@ Returns `{ main: { ... } }` or 404.
356
431
 
357
432
  `secondaries` keys are the **alias** declared in `allowedWriteJoins`. FK fields are auto-filled from the inserted main record.
358
433
 
434
+ > **Owned child tables (translations, `*_info` details): use a writeJoin, not a standalone table.**
435
+ > A table that only exists as a child of a parent — e.g. `product_info` with composite PK `(product_id, lang)` — should be an `allowedWriteJoins` on the parent, not its own `DbTables` entry. The engine auto-fills the FK (`product_id`); add it to `upsertMap` (conflict key = the composite PK) to upsert children passing only their own fields:
436
+ > ```typescript
437
+ > // on the parent (product) table:
438
+ > allowedWriteJoins: [
439
+ > buildRelation(SchemaProduct, 'id', SchemaProductInfo, 'productId', { alias: 'translations' }),
440
+ > ],
441
+ > upsertMap: buildUpsertRules(
442
+ > buildUpsertRule(SchemaProductInfo, ['productId', 'lang']), // composite conflict key
443
+ > ),
444
+ > // → PUT /rest/product { "main": {...}, "secondaries": { "translations": [{ "lang": "en", "name": "Bike" }] } }
445
+ > ```
446
+ > Expose a composite-PK table as a standalone CRUD table only when it stands on its own (M:N link tables, natural keys) — composite PKs are fully supported there too.
447
+
359
448
  **Response (201):** `{ main: { ... }, secondaries: { ... } }`
360
449
 
361
450
  ### PUT /rest/{table}
@@ -440,6 +529,17 @@ defineTable({
440
529
 
441
530
  Tables without `tenantScope` are unaffected.
442
531
 
532
+ ### Isolation guarantees on writes
533
+
534
+ Tenant isolation is enforced on write paths, not just reads:
535
+
536
+ - **Upsert conflicts are ownership-checked.** On a tenant-scoped table with an `upsertMap`, an
537
+ upsert (`POST /rest/:table` or `PUT /bulk/:table`) whose conflict key matches a row owned by
538
+ another tenant is rejected with `403` — it cannot overwrite or re-assign that row.
539
+ - **The tenant link cannot be changed to another tenant.** For direct scopes the tenant column is
540
+ stripped from update payloads. For indirect scopes, changing the through-FK (`localField`) is
541
+ allowed only to a value the caller owns; otherwise the update returns `403`.
542
+
443
543
  ## Swagger
444
544
 
445
545
  Enabled by passing `swagger: true` (or a config object) to the plugin options. Requires `@fastify/swagger` and `@fastify/swagger-ui` as peer dependencies.
@@ -495,7 +595,7 @@ await app.register(async (instance) => {
495
595
 
496
596
  ## Conventions
497
597
 
498
- - **camelCase everywhere in the API** — requests, responses, `validate`, and all hooks (`beforeInsert`, `beforeUpdate`, `afterInsert`) use schema field names
598
+ - **camelCase everywhere in the API** — requests, responses, `validate`, and all hooks (`beforeInsert`/`afterInsert`, `beforeUpdate`/`afterUpdate`, `beforeDelete`/`afterDelete`, `beforeBulkDelete`/`afterBulkDelete`) use schema field names
499
599
  - **Conversion to DB column format is automatic** via `colMap` — supports both snake_case and camelCase DB columns (e.g. betterauth-style)
500
600
  - **Aliases identify joins** — declared in `buildRelation({ alias })`, used as keys in request/response/`secondaries`/dotted notation
501
601
  - **`joinMustExist` / `joinMultiple` / `joinGroup`** are 1:N (child→main) and use side queries / EXISTS / correlated subqueries — no row duplication
@@ -549,7 +649,7 @@ After registering the plugin, `app.sqlApi` is decorated on the Fastify instance
549
649
  ## Requirements
550
650
 
551
651
  - Node.js >= 18
552
- - Fastify >= 4 (peer dependency)
652
+ - Fastify >= 5 (peer dependency)
553
653
  - One of:
554
654
  - **PostgreSQL** + `pg` + `@fastify/postgres`
555
655
  - **MySQL** / **MariaDB** + `mysql2`
@@ -54,10 +54,10 @@ await runCli('fastify-auto-sqlapi: generating schemas', async () => {
54
54
  let untouched = 0;
55
55
  const generatedFiles = new Set();
56
56
  for (const schemaName of Object.keys(tableMap)) {
57
- const { name: tableName, fields, colMap } = tableMap[schemaName];
57
+ const { name: tableName, fields, colMap, primary } = tableMap[schemaName];
58
58
  const filename = path.join(schemasDir, `${schemaName}.ts`);
59
59
  generatedFiles.add(`${schemaName}.ts`);
60
- const content = generateSchemaFile(schemaName, tableName, fields, colMap);
60
+ const content = generateSchemaFile(schemaName, tableName, fields, colMap, primary);
61
61
  let status = 'created';
62
62
  let color = CONSOLE_COLORS.green;
63
63
  if (fs.existsSync(filename)) {
package/dist/index.d.ts CHANGED
@@ -30,5 +30,5 @@ export { BulkDeleteTableBody, BulkDeleteTableResponse } from './lib/schema/bulk-
30
30
  export { default as bulkDeleteRoutes } from './routes/auto/bulk-delete.routes.js';
31
31
  export { Type, type Static } from '@sinclair/typebox';
32
32
  export { ConditionBuilder, Expression } from 'node-condition-builder';
33
- export type { SqlApiConfig, ColumnInfo, TableMap, Queryable, SqlResult, DbRecord, DbRecordValue, SelectOptions, SchemaDefinition, JoinDefinition, ITable, DbTables, SqlApiPluginOptions, SwaggerOptions, SearchParams, SearchResult, Paginator, PaginationResult, TableFilterFn, ExtendedConditionFn, FilterRecord, AggregationRequest, JoinGroupRequest, ComputedFieldFn, ComputedFieldContext, ComputedFieldExpr, GetParams, GetResult, DeleteParams, DeleteResult, InsertParams, InsertResult, BulkUpsertItem, BulkUpsertParams, BulkUpsertResult, BulkDeleteParams, BulkDeleteResult, ConditionMethod, SearchCondition, UpdateParams, UpdateResult, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantContext, ValidationError, ValidatorFn, BulkValidatorItem, BulkValidatorFn, } from './types.js';
33
+ export type { SqlApiConfig, ColumnInfo, TableMap, Queryable, TransactionQueryable, SqlResult, DbRecord, DbRecordValue, SelectOptions, SchemaDefinition, JoinDefinition, ITable, TableOperation, DbTables, SqlApiPluginOptions, SwaggerOptions, SearchParams, SearchResult, Paginator, PaginationResult, TableFilterFn, ExtendedConditionFn, FilterRecord, AggregationRequest, JoinGroupRequest, ComputedFieldFn, ComputedFieldContext, ComputedFieldExpr, GetParams, GetResult, DeleteParams, DeleteResult, InsertParams, InsertResult, BulkUpsertItem, BulkUpsertParams, BulkUpsertResult, BulkDeleteParams, BulkDeleteResult, ConditionMethod, SearchCondition, UpdateParams, UpdateResult, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantContext, ValidationError, ValidatorFn, BulkValidatorItem, BulkValidatorFn, } from './types.js';
34
34
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -4,6 +4,10 @@ export interface MysqlQueryable {
4
4
  affectedRows?: number;
5
5
  insertId?: number;
6
6
  }, unknown]>;
7
+ /** Present on mysql2 promise pools (and @fastify/mysql): checks out a dedicated connection. */
8
+ getConnection?(): Promise<MysqlQueryable & {
9
+ release(): void;
10
+ }>;
7
11
  }
8
12
  export declare function mysqlQueryable(pool: MysqlQueryable): Queryable;
9
13
  //# sourceMappingURL=mysql-adapter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mysql-adapter.d.ts","sourceRoot":"","sources":["../../../src/lib/adapters/mysql-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAa,MAAM,gBAAgB,CAAC;AAE3D,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,OAAO,EAAE,GACjB,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG;QAAE,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,OAAO,CAAC,CAAC,CAAC;CAC3E;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,SAAS,CAyB9D"}
1
+ {"version":3,"file":"mysql-adapter.d.ts","sourceRoot":"","sources":["../../../src/lib/adapters/mysql-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAmC,MAAM,gBAAgB,CAAC;AAEjF,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,OAAO,EAAE,GACjB,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG;QAAE,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,OAAO,CAAC,CAAC,CAAC;IAC1E,+FAA+F;IAC/F,aAAa,CAAC,IAAI,OAAO,CAAC,cAAc,GAAG;QAAE,OAAO,IAAI,IAAI,CAAA;KAAE,CAAC,CAAC;CACjE;AA2BD,wBAAgB,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,SAAS,CAc9D"}
@@ -1,21 +1,32 @@
1
- export function mysqlQueryable(pool) {
2
- return {
3
- async query(text, values) {
4
- const [result] = await pool.query(text, values);
5
- // SELECT returns an array of rows
6
- if (Array.isArray(result) && !('affectedRows' in result)) {
7
- return {
8
- rows: result,
9
- affectedRows: result.length,
10
- };
11
- }
12
- // INSERT/UPDATE/DELETE returns ResultSetHeader-like
13
- const header = result;
1
+ function wrapQuery(client) {
2
+ return async (text, values) => {
3
+ const [result] = await client.query(text, values);
4
+ // SELECT returns an array of rows
5
+ if (Array.isArray(result) && !('affectedRows' in result)) {
14
6
  return {
15
- rows: [],
16
- affectedRows: header.affectedRows ?? 0,
17
- insertId: header.insertId,
7
+ rows: result,
8
+ affectedRows: result.length,
18
9
  };
19
- },
10
+ }
11
+ // INSERT/UPDATE/DELETE returns ResultSetHeader-like
12
+ const header = result;
13
+ return {
14
+ rows: [],
15
+ affectedRows: header.affectedRows ?? 0,
16
+ insertId: header.insertId,
17
+ };
20
18
  };
21
19
  }
20
+ export function mysqlQueryable(pool) {
21
+ const queryable = { query: wrapQuery(pool) };
22
+ if (typeof pool.getConnection === 'function') {
23
+ queryable.connect = async () => {
24
+ const conn = await pool.getConnection();
25
+ return {
26
+ query: wrapQuery(conn),
27
+ release: () => conn.release(),
28
+ };
29
+ };
30
+ }
31
+ return queryable;
32
+ }
@@ -4,6 +4,10 @@ export interface PgQueryable {
4
4
  rows: T[];
5
5
  rowCount: number | null;
6
6
  }>;
7
+ /** Present on pg.Pool (and @fastify/postgres): checks out a dedicated client. */
8
+ connect?(): Promise<PgQueryable & {
9
+ release(): void;
10
+ }>;
7
11
  }
8
12
  export declare function pgQueryable(client: PgQueryable): Queryable;
9
13
  //# sourceMappingURL=pg-adapter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pg-adapter.d.ts","sourceRoot":"","sources":["../../../src/lib/adapters/pg-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAa,MAAM,gBAAgB,CAAC;AAE3D,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,OAAO,EAAE,GACjB,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,EAAE,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC;CACpD;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,CAa1D"}
1
+ {"version":3,"file":"pg-adapter.d.ts","sourceRoot":"","sources":["../../../src/lib/adapters/pg-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAmC,MAAM,gBAAgB,CAAC;AAEjF,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,OAAO,EAAE,GACjB,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,EAAE,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC;IACnD,iFAAiF;IACjF,OAAO,CAAC,IAAI,OAAO,CAAC,WAAW,GAAG;QAAE,OAAO,IAAI,IAAI,CAAA;KAAE,CAAC,CAAC;CACxD;AAeD,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,CAc1D"}
@@ -1,11 +1,22 @@
1
+ function wrapQuery(client) {
2
+ return async (text, values) => {
3
+ const result = await client.query(text, values);
4
+ return {
5
+ rows: result.rows,
6
+ affectedRows: result.rowCount ?? 0,
7
+ };
8
+ };
9
+ }
1
10
  export function pgQueryable(client) {
2
- return {
3
- async query(text, values) {
4
- const result = await client.query(text, values);
11
+ const queryable = { query: wrapQuery(client) };
12
+ if (typeof client.connect === 'function') {
13
+ queryable.connect = async () => {
14
+ const conn = await client.connect();
5
15
  return {
6
- rows: result.rows,
7
- affectedRows: result.rowCount ?? 0,
16
+ query: wrapQuery(conn),
17
+ release: () => conn.release(),
8
18
  };
9
- },
10
- };
19
+ };
20
+ }
21
+ return queryable;
11
22
  }
@@ -1 +1 @@
1
- {"version":3,"file":"mysql-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/mysql-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,wBAAgB,0BAA0B,IAAI;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAmBA;AAkDD,wBAAsB,qBAAqB,CACzC,gBAAgB,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,EAClG,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CAyBvB"}
1
+ {"version":3,"file":"mysql-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/mysql-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,wBAAgB,0BAA0B,IAAI;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAmBA;AAkDD,wBAAsB,qBAAqB,CACzC,gBAAgB,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,EAClG,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CA2BvB"}
@@ -70,7 +70,7 @@ export async function introspectMysqlTables(connectionConfig, schema) {
70
70
  const mysql2 = loadOptionalDependency('mysql2/promise', 'npm install mysql2');
71
71
  const connection = await mysql2.createConnection(connectionConfig);
72
72
  try {
73
- const [rows] = await connection.query(`SELECT table_name, column_name, data_type, column_default, is_nullable
73
+ const [rows] = await connection.query(`SELECT table_name, column_name, data_type, column_default, is_nullable, column_key, extra
74
74
  FROM information_schema.columns
75
75
  WHERE table_schema = ?
76
76
  ORDER BY table_name, ordinal_position`, [schema]);
@@ -81,6 +81,8 @@ export async function introspectMysqlTables(connectionConfig, schema) {
81
81
  udt_name: mapMysqlType(row.DATA_TYPE || row.data_type),
82
82
  column_default: row.COLUMN_DEFAULT || row.column_default,
83
83
  is_nullable: row.IS_NULLABLE || row.is_nullable,
84
+ is_primary: (row.COLUMN_KEY || row.column_key) === 'PRI',
85
+ is_auto_increment: String(row.EXTRA || row.extra || '').includes('auto_increment'),
84
86
  }));
85
87
  }
86
88
  finally {
@@ -1 +1 @@
1
- {"version":3,"file":"pg-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/pg-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,wBAAgB,qBAAqB,IAAI,MAAM,CAY9C;AAED,wBAAsB,gBAAgB,CACpC,gBAAgB,EAAE,MAAM,EACxB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CAqBvB"}
1
+ {"version":3,"file":"pg-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/pg-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,wBAAgB,qBAAqB,IAAI,MAAM,CAY9C;AAED,wBAAsB,gBAAgB,CACpC,gBAAgB,EAAE,MAAM,EACxB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CA8BvB"}
@@ -16,10 +16,19 @@ export async function introspectTables(connectionString, schema) {
16
16
  const client = new pg.Client({ connectionString });
17
17
  try {
18
18
  await client.connect();
19
- const result = await client.query(`SELECT table_name, column_name, udt_name, column_default, is_nullable
20
- FROM information_schema.columns
21
- WHERE table_schema = $1
22
- ORDER BY table_name, ordinal_position`, [schema]);
19
+ const result = await client.query(`SELECT c.table_name, c.column_name, c.udt_name, c.column_default, c.is_nullable,
20
+ (pk.column_name IS NOT NULL) AS is_primary
21
+ FROM information_schema.columns c
22
+ LEFT JOIN (
23
+ SELECT kcu.table_name, kcu.column_name
24
+ FROM information_schema.table_constraints tc
25
+ JOIN information_schema.key_column_usage kcu
26
+ ON tc.constraint_name = kcu.constraint_name
27
+ AND tc.table_schema = kcu.table_schema
28
+ WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1
29
+ ) pk ON pk.table_name = c.table_name AND pk.column_name = c.column_name
30
+ WHERE c.table_schema = $1
31
+ ORDER BY c.table_name, c.ordinal_position`, [schema]);
23
32
  return result.rows;
24
33
  }
25
34
  finally {
@@ -2,7 +2,8 @@ import type { ColumnInfo, TableMap } from '../../types.js';
2
2
  export declare function convertColType(udtName: string, columnInfo: {
3
3
  column_default: string | null;
4
4
  is_nullable: string;
5
+ is_auto_increment?: boolean;
5
6
  }): string;
6
7
  export declare function buildTableMap(rows: ColumnInfo[]): TableMap;
7
- export declare function generateSchemaFile(schemaName: string, tableName: string, fields: Record<string, string>, colMap: Record<string, string>): string;
8
+ export declare function generateSchemaFile(schemaName: string, tableName: string, fields: Record<string, string>, colMap: Record<string, string>, primary?: string[]): string;
8
9
  //# sourceMappingURL=schema-codegen.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"schema-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/schema-codegen.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE3D,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IAAE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GACjE,MAAM,CAoDR;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CAqB1D;AAED,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,MAAM,CA2CR"}
1
+ {"version":3,"file":"schema-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/schema-codegen.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE3D,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IAAE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAAE,GAC9F,MAAM,CAqDR;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CAqC1D;AAED,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,OAAO,CAAC,EAAE,MAAM,EAAE,GACjB,MAAM,CA4CR"}
@@ -1,6 +1,7 @@
1
1
  import { toCamelCase, toSchemaName } from '../naming.js';
2
2
  export function convertColType(udtName, columnInfo) {
3
- const hasDefault = columnInfo.column_default;
3
+ // AUTO_INCREMENT (mysql) behaves like a default: the DB generates the value.
4
+ const hasDefault = columnInfo.column_default || columnInfo.is_auto_increment;
4
5
  const isNullable = columnInfo.is_nullable === 'YES';
5
6
  const wrap = (t) => {
6
7
  return hasDefault || isNullable ? `Type.Optional(${t})` : t;
@@ -58,17 +59,30 @@ export function buildTableMap(rows) {
58
59
  name: row.table_name,
59
60
  fields: {},
60
61
  colMap: {},
62
+ primary: [],
61
63
  };
62
64
  }
63
65
  const fieldName = toCamelCase(row.column_name);
66
+ // Two columns mapping to the same camelCase field (e.g. user_name + userName) would
67
+ // silently overwrite each other in the colMap, dropping one column from the API.
68
+ const existing = acc[schemaName].colMap[fieldName];
69
+ if (existing !== undefined && existing !== row.column_name) {
70
+ throw new Error(`Naming collision on table "${row.table_name}": columns "${existing}" and ` +
71
+ `"${row.column_name}" both map to the API field "${fieldName}". ` +
72
+ `Rename one of the columns (or exclude the table) before generating schemas.`);
73
+ }
64
74
  acc[schemaName].fields[fieldName] = convertColType(row.udt_name, row);
65
75
  acc[schemaName].colMap[fieldName] = row.column_name;
76
+ if (row.is_primary) {
77
+ acc[schemaName].primary.push(fieldName);
78
+ }
66
79
  return acc;
67
80
  }, {});
68
81
  }
69
- export function generateSchemaFile(schemaName, tableName, fields, colMap) {
82
+ export function generateSchemaFile(schemaName, tableName, fields, colMap, primary) {
70
83
  const fieldsStr = JSON.stringify(fields, null, 2).replace(/"(.*?)"\s*:\s*"Type(.*?)"/g, '$1: Type$2');
71
84
  const colMapStr = JSON.stringify(colMap, null, 2);
85
+ const primaryKeyLine = primary?.length ? `\n primaryKey: ${JSON.stringify(primary)},` : '';
72
86
  return `// Auto-generated by fastify-auto-sqlapi. Do not edit.
73
87
  import { Type } from "fastify-auto-sqlapi";
74
88
  import type { Static } from "fastify-auto-sqlapi";
@@ -94,7 +108,7 @@ export const Schema = {
94
108
  fields: _Schema,
95
109
  validation,
96
110
  tableName: "${tableName}",
97
- partialValidation,
111
+ partialValidation,${primaryKeyLine}
98
112
  };
99
113
  export const ${schemaName} = Schema;
100
114
  export type TypeSchema = Static<typeof Schema.validation>;
@@ -3,6 +3,8 @@ export interface ParsedSchema {
3
3
  tableName: string;
4
4
  fields: string[];
5
5
  fieldTypes: Record<string, string>;
6
+ /** PRIMARY KEY fields declared in the schema file (`primaryKey: [...]`), when present. */
7
+ primary?: string[];
6
8
  }
7
9
  export interface DetectedRelation {
8
10
  parentSchemaName: string;
@@ -1 +1 @@
1
- {"version":3,"file":"tables-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/tables-codegen.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,gBAAgB;IAC/B,gBAAgB,EAAE,MAAM,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAID,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CA2BpE;AAgBD,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,gBAAgB,EAAE,CAwB3E;AAID,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,GAAG,MAAM,CAwHhG;AAID,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CAqBrE;AAID,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CA6FlE"}
1
+ {"version":3,"file":"tables-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/tables-codegen.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,gBAAgB,EAAE,MAAM,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAID,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAuCpE;AAuCD,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,gBAAgB,EAAE,CAwB3E;AAID,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,GAAG,MAAM,CAwHhG;AAID,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CAqBrE;AAID,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CA6FlE"}