fastify-auto-sqlapi 0.0.1 → 0.0.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.
@@ -0,0 +1,833 @@
1
+ # fastify-auto-sqlapi — Backend Configuration
2
+
3
+ How to set up schemas, tables, and configure the plugin.
4
+
5
+ ## Setup Workflow
6
+
7
+ ### 1. Install
8
+
9
+ ```bash
10
+ npm install fastify-auto-sqlapi
11
+ # PostgreSQL:
12
+ npm install fastify @fastify/postgres
13
+ # MySQL/MariaDB:
14
+ npm install fastify mysql2
15
+ # Optional (for Swagger UI):
16
+ npm install @fastify/swagger @fastify/swagger-ui
17
+ ```
18
+
19
+ ### 2. Configure `sqlapi.config.ts` (CLI only)
20
+
21
+ Create `sqlapi.config.ts` (or `.js`) in the project root. This config is used **only by the CLI** (`sqlapi-generate-schema` / `sqlapi-generate-tables`), NOT at runtime by the Fastify plugin.
22
+
23
+ ```typescript
24
+ // sqlapi.config.ts — interface: { outputDir: string; schema?: string; dialect?: DialectName }
25
+ export default {
26
+ outputDir: './src/db', // base directory for generated files (default: './src/db')
27
+ schema: 'public', // DB schema to introspect (default: 'public', PostgreSQL only)
28
+ dialect: 'postgres', // 'postgres' | 'mysql' | 'mariadb' (default: 'postgres')
29
+ };
30
+ ```
31
+
32
+ That's it — only these three fields exist. **No connection string, no migration path.** If the file is missing, defaults are used. The `dialect` can also be passed via CLI flag `--dialect mysql`.
33
+
34
+ ### 3. Configure database connection (env vars)
35
+
36
+ The CLI reads DB connection from environment variables (NOT from the config file). `DATABASE_URL` takes priority for all dialects.
37
+
38
+ **PostgreSQL:**
39
+ - `DATABASE_URL` — full connection string, OR:
40
+ - `POSTGRES_HOST` (default: `127.0.0.1`), `POSTGRES_PORT` (default: `5433`), `POSTGRES_USER` (default: `test`), `POSTGRES_PASSWORD` (default: `test`), `POSTGRES_DB` (default: `testdb`)
41
+
42
+ **MySQL/MariaDB:**
43
+ - `DATABASE_URL` — full connection string, OR:
44
+ - `MYSQL_HOST` (default: `127.0.0.1`), `MYSQL_PORT` (default: `3306`), `MYSQL_USER` (default: `test`), `MYSQL_PASSWORD` (default: `test`), `MYSQL_DB` (default: `testdb`)
45
+
46
+ The CLI automatically loads a `.env` file from the current working directory (if it exists). Variables already set in the environment are not overridden.
47
+
48
+ ```
49
+ # .env
50
+ DATABASE_URL=postgres://user:pass@localhost:5432/mydb
51
+ ```
52
+
53
+ ```json
54
+ "scripts": {
55
+ "sqlapi:generate-schema": "sqlapi-generate-schema",
56
+ "sqlapi:generate-tables": "sqlapi-generate-tables --all"
57
+ }
58
+ ```
59
+
60
+ At runtime, the Fastify plugin uses `@fastify/postgres` (for PG) or `mysql2` (for MySQL/MariaDB) which you configure separately (see step 6).
61
+
62
+ ### 4. Generate Schema files
63
+
64
+ ```bash
65
+ npx sqlapi-generate-schema # PostgreSQL (default)
66
+ npx sqlapi-generate-schema --dialect mysql # MySQL/MariaDB
67
+ ```
68
+
69
+ This introspects the database and generates one `Schema*.ts` file per table in `outputDir/schemas/`. These files are auto-generated and should not be manually edited. They contain TypeBox field definitions, `col()` for camelCase→snake_case mapping, and validation schemas. MySQL/MariaDB requires `mysql2` as a peer dependency.
70
+
71
+ ### 5. Generate tables template
72
+
73
+ ```bash
74
+ npx sqlapi-generate-tables customer customer_order # specific tables (space separated)
75
+ npx sqlapi-generate-tables customer,customer_order # specific tables (comma separated)
76
+ npx sqlapi-generate-tables --all # all tables
77
+ ```
78
+
79
+ This reads the Schema files from `outputDir/schemas/` and generates one `Table*.ts` file per table plus a `dbTables.ts` index in `outputDir/tables/`:
80
+
81
+ ```
82
+ src/db/
83
+ schemas/
84
+ SchemaCustomer.ts # from generate-schema (do not edit)
85
+ SchemaCustomerOrder.ts
86
+ tables/
87
+ TableCustomer.ts # generated — skip if already exists
88
+ TableCustomerOrder.ts
89
+ dbTables.ts # generated — skip if already exists
90
+ ```
91
+
92
+ Each `Table*.ts` file contains a `defineTable()` call with:
93
+ - Auto-detected primary keys
94
+ - Auto-detected foreign key relations (from field naming convention `*Id`)
95
+ - Commented example of `extraFiltersValidation` + `extendedCondition`
96
+ - All optional keys as commented code, ready to uncomment
97
+ - `export const TableXxx` (named export)
98
+
99
+ **No files are ever overwritten** — if they already exist, they are skipped. This makes it safe to re-run the command when new tables are added to the database. Import paths are generated without extensions (the consumer's tsconfig decides resolution).
100
+
101
+ **Edit the `Table*.ts` and `dbTables.ts` files to customize** — these files are yours to maintain.
102
+
103
+ ### 6. Create the Fastify server
104
+
105
+ **PostgreSQL setup:**
106
+
107
+ ```typescript
108
+ import Fastify from 'fastify';
109
+ import fastifyPostgres from '@fastify/postgres';
110
+ import { fastifyAutoSqlApi } from 'fastify-auto-sqlapi';
111
+ import { dbTables } from './src/db/tables/dbTables.js';
112
+
113
+ const app = Fastify();
114
+
115
+ await app.register(fastifyPostgres, {
116
+ connectionString: 'postgres://user:pass@localhost:5432/mydb',
117
+ });
118
+
119
+ await app.register(fastifyAutoSqlApi, {
120
+ DbTables: dbTables, // REQUIRED — Record<string, ITable>
121
+ // dialect: 'postgres', // optional — default is 'postgres'
122
+ swagger: true, // optional — true or SwaggerOptions object
123
+ prefix: '/auto', // optional — standard Fastify prefix
124
+ onRequests: [], // optional — global auth hooks (run on every route)
125
+ getTenantId: (request) => request.user?.organizationId ?? null, // optional — multi-tenant
126
+ debug: true, // optional — log all SQL queries and params to console
127
+ });
128
+
129
+ await app.listen({ port: 3000 });
130
+ ```
131
+
132
+ **MySQL/MariaDB setup:**
133
+
134
+ ```typescript
135
+ import Fastify from 'fastify';
136
+ import mysql from 'mysql2/promise';
137
+ import { fastifyAutoSqlApi, mysqlQueryable } from 'fastify-auto-sqlapi';
138
+ import { dbTables } from './src/db/tables/dbTables.js';
139
+
140
+ const app = Fastify();
141
+
142
+ const pool = mysql.createPool({
143
+ host: '127.0.0.1', port: 3306,
144
+ user: 'root', password: 'pass', database: 'mydb',
145
+ });
146
+ app.decorate('mysql', pool);
147
+
148
+ await app.register(fastifyAutoSqlApi, {
149
+ DbTables: dbTables,
150
+ dialect: 'mysql', // or 'mariadb' (MariaDB supports RETURNING)
151
+ swagger: true,
152
+ prefix: '/auto',
153
+ });
154
+
155
+ await app.listen({ port: 3000 });
156
+ ```
157
+
158
+ **Granular composition (any dialect):**
159
+
160
+ ```typescript
161
+ import {
162
+ searchRoutes, getRoutes, insertRoutes, updateRoutes,
163
+ deleteRoutes, bulkUpsertRoutes, bulkDeleteRoutes, setupSwagger,
164
+ } from 'fastify-auto-sqlapi';
165
+
166
+ await app.register(async (instance) => {
167
+ await setupSwagger(instance, { swagger: true });
168
+
169
+ const opts = { DbTables: dbTables, dialect: 'mysql' };
170
+ await instance.register(searchRoutes, opts);
171
+ await instance.register(getRoutes, opts);
172
+ await instance.register(insertRoutes, opts);
173
+ await instance.register(updateRoutes, opts);
174
+ await instance.register(deleteRoutes, opts);
175
+ await instance.register(bulkUpsertRoutes, opts);
176
+ await instance.register(bulkDeleteRoutes, opts);
177
+ }, { prefix: '/auto' });
178
+ ```
179
+
180
+ **Note**: After registering the plugin, `app.sqlApi` is available for custom routes — see [SqlApi](#sqlapi--programmatic-high-level-api).
181
+
182
+ ---
183
+
184
+ ## defineTable() — Complete Reference
185
+
186
+ ```typescript
187
+ import {defineTable, exportTableInfo, Type} from 'fastify-auto-sqlapi';
188
+ // Import additional utilities only when needed:
189
+ // import {buildRelation, buildUpsertRules, buildUpsertRule, ConditionBuilder} from 'fastify-auto-sqlapi';
190
+ // import type {DbTables} from 'fastify-auto-sqlapi';
191
+ ```
192
+
193
+ ### Minimal table
194
+
195
+ ```typescript
196
+ import {defineTable, exportTableInfo} from 'fastify-auto-sqlapi';
197
+ import {SchemaCustomer as Schema} from '../schemas/SchemaCustomer';
198
+
199
+ export const TableCustomer = defineTable({
200
+ primary: 'id',
201
+ ...exportTableInfo(Schema),
202
+ });
203
+ ```
204
+
205
+ `exportTableInfo(Schema)` returns `{ Schema, filters, extraFilters }`. The `filters` function auto-builds WHERE conditions from any schema field present in the request.
206
+
207
+ **Important**: there is no `tableName` key in `defineTable()`. The SQL table name comes from `Schema.tableName` (set by the CLI). The route URL name is the key you use in the `dbTables` record (e.g. `{ customer: TableCustomer }` → `/search/customer`).
208
+
209
+ ### Composite primary key (link/junction tables)
210
+
211
+ ```typescript
212
+ import {defineTable, exportTableInfo} from 'fastify-auto-sqlapi';
213
+ import {SchemaAgentTeamLink as Schema} from '../schemas/SchemaAgentTeamLink';
214
+
215
+ export const TableAgentTeamLink = defineTable({
216
+ primary: ['agentId', 'teamId'],
217
+ ...exportTableInfo(Schema),
218
+ });
219
+ ```
220
+
221
+ When `primary` is an array, RETURNING clauses include all PK columns, and response shapes contain all PK fields.
222
+
223
+ ### All keys
224
+
225
+ ```typescript
226
+ export const TableCustomer = defineTable({
227
+ // REQUIRED
228
+ primary: 'id', // PK field name (camelCase), or array for composite: ['agentId', 'teamId']
229
+ ...exportTableInfo(Schema), // Schema + auto-filter builder
230
+
231
+ // OPTIONAL
232
+ defaultOrder: 'name', // ORDER BY default (supports multi: 'name ASC, id DESC')
233
+ excludeFromCreation: ['id'], // Fields omitted from INSERT — MUST include auto-increment PKs
234
+ distinctResults: true, // Use SELECT DISTINCT
235
+
236
+ // JOINS — relations to other tables
237
+ allowedReadJoins: [ // Available for search queries (joins, joinFilters, joinGroups)
238
+ buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'),
239
+ buildRelation(SchemaCustomer, 'id', SchemaAddress, 'customerId', 'id, city, zip'),
240
+ ],
241
+ allowedWriteJoins: [ // Available for insert/update secondaries
242
+ buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'),
243
+ ],
244
+
245
+ // UPSERT — ON CONFLICT resolution
246
+ upsertMap: buildUpsertRules(
247
+ buildUpsertRule(SchemaCustomer, ['id']), // main table conflict key
248
+ buildUpsertRule(SchemaOrder, ['id']), // secondary conflict key
249
+ ),
250
+
251
+ // TENANT — automatic row-level isolation
252
+ tenantScope: { column: 'organization_id' }, // direct: column on this table
253
+ // OR indirect: resolve via JOIN to parent table
254
+ // tenantScope: {
255
+ // column: 'organization_id',
256
+ // through: { schema: SchemaCustomer, localField: 'customerId', foreignField: 'id' },
257
+ // },
258
+
259
+ // SCHEMA OVERRIDES — tighten auto-generated schema validation without editing Schema files
260
+ schemaOverrides: {
261
+ email: Type.String({ format: 'email' }),
262
+ name: Type.String({ minLength: 1, maxLength: 100 }),
263
+ },
264
+
265
+ // VALIDATION — structured field-level validation (runs after schema, before hooks)
266
+ validate: async (db, req, main, secondaries) => {
267
+ // Return ValidationError[] — tuple: [field, code] or [field, code, message]
268
+ // message defaults to code if omitted
269
+ const errors = [];
270
+ if (main.name === '') {
271
+ errors.push(['name', 'required', 'cannot be empty']);
272
+ }
273
+ // Cross-entity: validate secondaries (e.g. date overlap in periods)
274
+ if (secondaries?.customer_order) {
275
+ // ... check overlaps, business rules across related records
276
+ }
277
+ return errors;
278
+ },
279
+ validateBulk: async (db, req, items) => {
280
+ // Called once with ALL items in bulk-upsert. Use for cross-item validation.
281
+ // items: Array<{ main, secondaries? }>
282
+ return []; // ValidationError[]
283
+ },
284
+
285
+ // HOOKS — side effects (runs after validation)
286
+ beforeInsert: async (db, req, record) => {
287
+ // Mutate record before INSERT. record is snake_case.
288
+ record.created_by = req.user.id;
289
+ },
290
+ afterInsert: async (db, req, record, secondaryRecords) => {
291
+ // Called after INSERT + secondaries. record is camelCase.
292
+ },
293
+ beforeUpdate: async (db, req, fields) => {
294
+ // Mutate fields before UPDATE. fields is snake_case.
295
+ fields.updated_by = req.user.id;
296
+ },
297
+
298
+ // AUTH — per-table request hooks
299
+ onRequests: [
300
+ async (request, reply) => {
301
+ if (!request.user) return reply.status(401).send({ error: 'Unauthorized' });
302
+ },
303
+ ],
304
+ });
305
+ ```
306
+
307
+ ### buildRelation signature
308
+
309
+ ```typescript
310
+ buildRelation(mainSchema, mainField, joinSchema, joinField, selection?)
311
+ ```
312
+
313
+ - `mainField`: field(s) in main table (string or string[])
314
+ - `joinField`: field in join table that references the main
315
+ - `selection`: SQL columns to select (default `'*'`), e.g. `'id, name, total'`
316
+
317
+ The join works as: `SELECT {selection} FROM {joinTable} WHERE {joinField} IN ({mainField values})`
318
+
319
+ ### extraFilters + extendedCondition
320
+
321
+ For filters that don't map to real columns (e.g. full-text search `q`):
322
+
323
+ ```typescript
324
+ import {defineTable, exportTableInfo, Type, ConditionBuilder} from 'fastify-auto-sqlapi';
325
+ import {SchemaCustomer as Schema} from '../schemas/SchemaCustomer';
326
+
327
+ const extraFiltersValidation = Type.Object({
328
+ q: Type.String(),
329
+ });
330
+
331
+ export const TableCustomer = defineTable({
332
+ primary: 'id',
333
+ ...exportTableInfo(
334
+ Schema,
335
+ extraFiltersValidation,
336
+ // `filters` is auto-typed with keys from Schema.fields + extraFiltersValidation
337
+ (condition, filters) => {
338
+ if (filters.q) {
339
+ const or = new ConditionBuilder('OR');
340
+ or.isILike(Schema.col('name'), `%${filters.q}%`);
341
+ or.isILike(Schema.col('email'), `%${filters.q}%`);
342
+ condition.append(or);
343
+ }
344
+ }
345
+ ),
346
+ });
347
+ ```
348
+
349
+ `extraFilters` accepts either a `Type.Object({...})` or a plain `Record<string, TSchema>`. Extra filter fields appear in Swagger but are NOT auto-applied as `WHERE col = value` — they are handled exclusively by the `extendedCondition` callback. The `filters` parameter in the callback is fully typed with autocomplete for all schema fields + extra filter keys.
350
+
351
+ ---
352
+
353
+ ## ConditionBuilder API
354
+
355
+ Used in `extendedCondition` callbacks, hooks, and exposed via the `conditions` field in the search API (see [AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md#conditions-advanced-filters)).
356
+
357
+ ```typescript
358
+ const cb = new ConditionBuilder('AND'); // or 'OR'
359
+ cb.isEqual('column', value);
360
+ cb.isNotEqual('column', value);
361
+ cb.isGreater('column', value);
362
+ cb.isGreaterOrEqual('column', value);
363
+ cb.isLess('column', value);
364
+ cb.isLessOrEqual('column', value);
365
+ cb.isLike('column', '%value%');
366
+ cb.isILike('column', '%value%'); // case-insensitive LIKE
367
+ cb.isBetween('column', from, to);
368
+ cb.isIn('column', [val1, val2]);
369
+ cb.isNotIn('column', [val1, val2]);
370
+ cb.isNull('column', true);
371
+ cb.isNotNull('column', true);
372
+ cb.raw('column::text = $1', [value]); // raw SQL with params (backend only, NOT exposed in API)
373
+ cb.append(otherConditionBuilder); // nest conditions (backend only, NOT exposed in API)
374
+ ```
375
+
376
+ All field-based methods (except `raw` and `append`) are available in the search API `conditions` array. New methods added to ConditionBuilder are automatically available after adding them to the whitelist.
377
+
378
+ All values are parameterized (`$1, $2, ...`), never interpolated.
379
+
380
+ ---
381
+
382
+ ## SqlApi — Programmatic High-Level API
383
+
384
+ Use `SqlApi` to perform CRUD operations from custom routes with the same capabilities as the auto-generated endpoints. The internal auto-generated routes also use `SqlApi`, guaranteeing a single code path.
385
+
386
+ ### Using `app.sqlApi` (recommended)
387
+
388
+ After registering the plugin, `app.sqlApi` is available everywhere — no extra configuration needed:
389
+
390
+ ```typescript
391
+ await app.register(fastifyAutoSqlApi, {
392
+ DbTables: dbTables,
393
+ dialect: 'mysql',
394
+ swagger: true,
395
+ prefix: '/auto',
396
+ });
397
+
398
+ // app.sqlApi is available in any route — even outside the plugin scope
399
+ app.get('/billing/:customerId', async (request) => {
400
+ const customerId = Number(request.params.customerId);
401
+ return app.sqlApi.search('subscription', {
402
+ filters: { customerId, status: 'active' },
403
+ joinFilters: { customer_order: { status: 'pending' } },
404
+ paginator: { page: 1, itemsPerPage: 50 },
405
+ }, request);
406
+ });
407
+ ```
408
+
409
+ `app.sqlApi` inherits all configuration from the plugin registration (dialect, DbTables, tenant, debug). No need to pass them again.
410
+
411
+ ### Using `createSqlApi` (standalone — for background jobs or without the plugin)
412
+
413
+ When you need a `SqlApi` instance without registering the full plugin (e.g. background jobs, scripts, tests):
414
+
415
+ ```typescript
416
+ import { createSqlApi, mysqlQueryable } from 'fastify-auto-sqlapi';
417
+
418
+ // Option 1: pass a raw pool — SqlApi creates the QueryClient internally
419
+ const sqlApi = createSqlApi(mysqlQueryable(pool), dbTables, { dialect: 'mysql' });
420
+
421
+ // Option 2: pass a pre-built QueryClient
422
+ import { createQueryClient } from 'fastify-auto-sqlapi';
423
+ const db = createQueryClient(mysqlQueryable(pool), 'mysql');
424
+ const sqlApi = createSqlApi(db, dbTables, { dialect: 'mysql' });
425
+ ```
426
+
427
+ **Important**: when using `createSqlApi` standalone, always pass `dialect` in the options. It configures both the QueryClient (identifier quoting, placeholders) and the ConditionBuilder (used by filters). Without it, defaults to PostgreSQL syntax.
428
+
429
+ ### Available methods
430
+
431
+ ```typescript
432
+ // Search — full filter, join, pagination, aggregation support
433
+ sqlApi.search(tableName, {
434
+ filters?, joinFilters?, joins?, joinGroups?,
435
+ orderBy?, paginator?, computeMin?, computeMax?, computeSum?, computeAvg?,
436
+ }, request?): Promise<SearchResult>
437
+
438
+ // Get single record by PK
439
+ sqlApi.get(tableName, id, request?): Promise<GetResult>
440
+
441
+ // Insert — with optional secondaries, hooks, tenant
442
+ sqlApi.insert(tableName, { record, secondaries? }, request?): Promise<InsertResult>
443
+
444
+ // Update — with optional secondaries, deletions, hooks, tenant
445
+ sqlApi.update(tableName, { record, secondaries?, deletions? }, request?): Promise<UpdateResult>
446
+
447
+ // Delete by PK
448
+ sqlApi.delete(tableName, id, request?): Promise<DeleteResult>
449
+
450
+ // Bulk upsert
451
+ sqlApi.bulkUpsert(tableName, items, request?): Promise<BulkUpsertResult[]>
452
+
453
+ // Bulk delete
454
+ sqlApi.bulkDelete(tableName, ids, request?): Promise<BulkDeleteResult[]>
455
+ ```
456
+
457
+ The `request` parameter is optional. Pass it when you need tenant resolution or hooks (which receive `req`). Without it, tenant filtering is skipped and hooks receive `undefined` as the request.
458
+
459
+ ---
460
+
461
+ ## QueryClient API
462
+
463
+ Available in hooks via the `db` parameter:
464
+
465
+ ```typescript
466
+ db.insert(tableName, record, pkCol); // INSERT, returns PK row
467
+ db.insertOrUpdate(tableName, record, conflictKeys, pkCol); // INSERT ON CONFLICT, returns PK row
468
+ db.bulkInsert(tableName, records, pkCol, chunkSize?); // Multi-row INSERT, returns PK rows
469
+ db.bulkInsertOrUpdate(tableName, records, conflictKeys, pkCol, chunkSize?); // Multi-row UPSERT, returns PK rows
470
+ db.update(tableName, record, where, extraCondition?); // UPDATE, returns affectedRows (number)
471
+ db.delete(tableName, where); // DELETE, returns affectedRows (number)
472
+ db.select({ tableName, columns?, where, values, limit?, orderBy?, joins?, distinct? }); // SELECT, returns rows
473
+ db.query(sql, values); // Raw query
474
+ db.expression(value); // Raw SQL expression (not parameterized)
475
+ ```
476
+
477
+ `pkCol` can be a string (`'id'`) or an array (`['agent_id', 'team_id']`) for composite PKs. PostgreSQL/MariaDB use `RETURNING`, MySQL uses `insertId`.
478
+
479
+ Record keys are snake_case column names. Values are parameterized.
480
+
481
+ ---
482
+
483
+ ## Swagger
484
+
485
+ ```typescript
486
+ import { setupSwagger } from 'fastify-auto-sqlapi';
487
+
488
+ // Basic (UI at /documentation)
489
+ await setupSwagger(instance, { swagger: true });
490
+
491
+ // Custom
492
+ await setupSwagger(instance, {
493
+ swagger: {
494
+ title: 'My API',
495
+ description: 'Auto-generated CRUD API',
496
+ version: '1.0.0',
497
+ routePrefix: '/docs',
498
+ },
499
+ });
500
+ ```
501
+
502
+ Requires `@fastify/swagger` and `@fastify/swagger-ui` as peer deps. If not installed, logs a warning and continues.
503
+
504
+ ---
505
+
506
+ ## Multi-Tenant Filtering
507
+
508
+ Automatic row-level isolation per tenant on all CRUD operations. Zero code in route handlers — just configure and go.
509
+
510
+ ### Setup
511
+
512
+ ```typescript
513
+ import { fastifyAutoSqlApi } from 'fastify-auto-sqlapi';
514
+
515
+ await app.register(fastifyAutoSqlApi, {
516
+ DbTables: dbTables,
517
+ // Return tenant ID(s) from request. null/undefined = admin (no filter).
518
+ getTenantId: (request) => {
519
+ const orgId = request.user?.organizationId;
520
+ return orgId ?? null;
521
+ },
522
+ });
523
+ ```
524
+
525
+ ### Direct tenant (column on the table itself)
526
+
527
+ ```typescript
528
+ const TableCustomer = defineTable({
529
+ primary: 'id',
530
+ ...exportTableInfo(SchemaCustomer),
531
+ tenantScope: { column: 'organization_id' },
532
+ });
533
+ ```
534
+
535
+ Behavior:
536
+ - **Read** (search, get, delete, bulk-delete): adds `AND organization_id IN ($N)` to WHERE
537
+ - **Insert**: auto-injects `organization_id` if single tenant; validates if already present (403 if mismatch); 400 if multi-tenant without explicit value
538
+ - **Update**: strips `organization_id` from SET (can't change tenant), adds to WHERE condition
539
+ - **Bulk upsert**: auto-injects on all records
540
+
541
+ ### Indirect tenant (via JOIN to parent table)
542
+
543
+ ```typescript
544
+ const TableOrder = defineTable({
545
+ primary: 'id',
546
+ ...exportTableInfo(SchemaOrder),
547
+ tenantScope: {
548
+ column: 'organization_id',
549
+ through: {
550
+ schema: SchemaCustomer, // parent table schema
551
+ localField: 'customer_id', // FK on this table
552
+ foreignField: 'id', // PK on parent table
553
+ },
554
+ },
555
+ });
556
+ ```
557
+
558
+ Behavior:
559
+ - **Read**: INNER JOIN to parent table + WHERE on parent's tenant column
560
+ - **Insert/Bulk upsert**: validates FK references belong to tenant (1 batch query)
561
+ - **Update**: pre-check via SELECT with INNER JOIN (404 if not found)
562
+ - **Delete**: subquery `DELETE WHERE pk IN (SELECT ... INNER JOIN ... WHERE tenant IN (...))`
563
+
564
+ ### Multi-tenant users
565
+
566
+ ```typescript
567
+ getTenantId: (request) => {
568
+ // Return array for users managing multiple tenants
569
+ return request.user?.organizationIds ?? null; // e.g. [1, 2, 3]
570
+ },
571
+ ```
572
+
573
+ Uses `IN ($N, $M, ...)` for all WHERE clauses (same as single tenant, just with more values).
574
+
575
+ ### Admin bypass
576
+
577
+ When `getTenantId` returns `null` or `undefined`, no filtering is applied (full access).
578
+
579
+ ### Tables without tenant
580
+
581
+ Tables without `tenantScope` are unaffected — no filtering regardless of `getTenantId` result.
582
+
583
+ ### Error codes
584
+
585
+ - **403** — Record doesn't belong to tenant, or explicit tenant value doesn't match
586
+ - **400** — Multi-tenant user on insert without explicit tenant value (ambiguous)
587
+ - **404** — Update with indirect tenant, record not found for this tenant
588
+
589
+ ---
590
+
591
+ ## Key Conventions
592
+
593
+ - **camelCase in API, snake_case in DB**: all request/response fields are camelCase. The plugin converts automatically via `col()`.
594
+ - **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
595
+ - **`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.
596
+ - **`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.
597
+ - **`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.
598
+ - **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.
599
+ - **`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.
600
+ - **Hooks receive snake_case, validate receives camelCase**: `validate(db, req, main, secondaries)` gets camelCase (pre-conversion). `beforeInsert(db, req, record)` and `beforeUpdate(db, req, fields)` get snake_case (post-conversion). `afterInsert` receives camelCase (post-DB).
601
+ - **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 } } })`.
602
+ - **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).
603
+
604
+ ---
605
+
606
+ ## Common Backend Patterns
607
+
608
+ ### Auth middleware (global)
609
+
610
+ ```typescript
611
+ const opts = {
612
+ DbTables: dbTables,
613
+ onRequests: [async (req, reply) => {
614
+ if (!req.headers.authorization) return reply.status(401).send({ error: 'Unauthorized' });
615
+ }],
616
+ };
617
+ ```
618
+
619
+ ### Auth middleware (per-table)
620
+
621
+ ```typescript
622
+ const TableAdmin = defineTable({
623
+ primary: 'id',
624
+ ...exportTableInfo(SchemaAdmin),
625
+ onRequests: [async (req, reply) => {
626
+ if (req.user.role !== 'admin') return reply.status(403).send({ error: 'Forbidden' });
627
+ }],
628
+ });
629
+ ```
630
+
631
+ ### Custom validation (field-level, cross-entity)
632
+
633
+ ```typescript
634
+ import type { ValidationError } from 'fastify-auto-sqlapi';
635
+
636
+ const TableSession = defineTable({
637
+ primary: 'id',
638
+ ...exportTableInfo(SchemaSession),
639
+ allowedWriteJoins: [
640
+ buildRelation(SchemaSession, 'id', SchemaPeriod, 'sessionId'),
641
+ ],
642
+ validate: async (db, req, main, secondaries) => {
643
+ // ValidationError is a tuple: [field, code] or [field, code, message]
644
+ // message defaults to code if omitted
645
+ const errors: ValidationError[] = [];
646
+
647
+ // Simple field validation
648
+ if (!main.name) {
649
+ errors.push(['name', 'required']); // message defaults to 'required'
650
+ }
651
+
652
+ // Async validation (uniqueness check)
653
+ if (main.code) {
654
+ const existing = await db.query('SELECT 1 FROM session WHERE code = $1 AND id != $2', [main.code, main.id ?? 0]);
655
+ if (existing.rows.length) {
656
+ errors.push(['code', 'unique', 'already exists']);
657
+ }
658
+ }
659
+
660
+ // Cross-entity: validate periods don't overlap
661
+ const periods = secondaries?.session_period;
662
+ if (periods?.length) {
663
+ for (let i = 0; i < periods.length; i++) {
664
+ for (let j = i + 1; j < periods.length; j++) {
665
+ if (periods[i].startDate < periods[j].endDate && periods[j].startDate < periods[i].endDate) {
666
+ errors.push([`session_period[${j}].startDate`, 'overlap', 'overlaps with another period']);
667
+ }
668
+ }
669
+ }
670
+ }
671
+
672
+ return errors;
673
+ },
674
+ });
675
+ ```
676
+
677
+ `validate` runs on insert, update, and each item in bulk-upsert (when `validateBulk` is not defined). When `validateBulk` is defined, it **replaces** per-item `validate` in bulk operations — use it for optimized batch queries and cross-item validation:
678
+
679
+ ```typescript
680
+ const TableSession = defineTable({
681
+ // ...
682
+ validateBulk: async (db, req, items) => {
683
+ // items: Array<{ main, secondaries? }>
684
+ // Check for duplicate codes across all items in the batch
685
+ const codes = items.map(i => i.main.code).filter(Boolean);
686
+ const unique = new Set(codes);
687
+ if (unique.size !== codes.length) {
688
+ return [['code', 'batch_unique', 'duplicate codes in batch']];
689
+ }
690
+ return [];
691
+ },
692
+ });
693
+ ```
694
+
695
+ ### Auto-fill audit fields
696
+
697
+ ```typescript
698
+ const TableCustomer = defineTable({
699
+ primary: 'id',
700
+ ...exportTableInfo(SchemaCustomer),
701
+ beforeInsert: async (db, req, record) => {
702
+ record.created_by = req.user.id;
703
+ record.created_at = db.expression('NOW()');
704
+ },
705
+ beforeUpdate: async (db, req, fields) => {
706
+ fields.updated_by = req.user.id;
707
+ fields.updated_at = db.expression('NOW()');
708
+ },
709
+ });
710
+ ```
711
+
712
+ ### Full-text search filter
713
+
714
+ ```typescript
715
+ const extraFilters = Type.Object({ q: Type.String() });
716
+
717
+ ...exportTableInfo(Schema, extraFilters, (condition, filters) => {
718
+ if (filters.q) {
719
+ const or = new ConditionBuilder('OR');
720
+ or.isILike(Schema.col('name'), `%${filters.q}%`);
721
+ or.isILike(Schema.col('email'), `%${filters.q}%`);
722
+ or.isILike(Schema.col('phoneNumber'), `%${filters.q}%`);
723
+ condition.append(or);
724
+ }
725
+ }),
726
+ ```
727
+
728
+ ### Date range filter
729
+
730
+ ```typescript
731
+ const extraFilters = Type.Object({
732
+ dateFrom: Type.String(),
733
+ dateTo: Type.String(),
734
+ });
735
+
736
+ ...exportTableInfo(Schema, extraFilters, (condition, filters) => {
737
+ if (filters.dateFrom && filters.dateTo) {
738
+ condition.isBetween(Schema.col('orderDate'), filters.dateFrom, filters.dateTo);
739
+ } else if (filters.dateFrom) {
740
+ condition.isGreater(Schema.col('orderDate'), filters.dateFrom);
741
+ }
742
+ }),
743
+ ```
744
+
745
+ ### Multi-tenant from JWT/header
746
+
747
+ ```typescript
748
+ await app.register(fastifyAutoSqlApi, {
749
+ DbTables: dbTables,
750
+ getTenantId: (request) => {
751
+ // From JWT claims
752
+ return request.user?.organizationId ?? null;
753
+
754
+ // Or from header (multi-tenant support)
755
+ // const header = request.headers['x-tenant-id'] as string | undefined;
756
+ // if (!header) return null;
757
+ // const ids = header.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
758
+ // return ids.length === 0 ? null : ids.length === 1 ? ids[0] : ids;
759
+ },
760
+ });
761
+ ```
762
+
763
+ ### Register only specific routes
764
+
765
+ You don't have to register all route plugins. Pick only what you need:
766
+
767
+ ```typescript
768
+ await instance.register(searchRoutes, opts); // read-only API
769
+ await instance.register(getRoutes, opts);
770
+ // No insert/update/delete/bulk routes = read-only
771
+ ```
772
+
773
+ ### Multiple prefixes for different access levels
774
+
775
+ ```typescript
776
+ // Public: search + get only
777
+ await app.register(async (instance) => {
778
+ const opts = { DbTables: publicTables };
779
+ await instance.register(searchRoutes, opts);
780
+ await instance.register(getRoutes, opts);
781
+ }, { prefix: '/public' });
782
+
783
+ // Admin: full CRUD
784
+ await app.register(async (instance) => {
785
+ const opts = { DbTables: allTables, onRequests: [adminAuth] };
786
+ await instance.register(searchRoutes, opts);
787
+ await instance.register(getRoutes, opts);
788
+ await instance.register(insertRoutes, opts);
789
+ await instance.register(updateRoutes, opts);
790
+ await instance.register(deleteRoutes, opts);
791
+ await instance.register(bulkUpsertRoutes, opts);
792
+ await instance.register(bulkDeleteRoutes, opts);
793
+ }, { prefix: '/admin' });
794
+ ```
795
+
796
+ ---
797
+
798
+ ## FAQ / Gotchas
799
+
800
+ ### Schema files `default export` — do I need to register them?
801
+
802
+ No. The generated Schema files export a default Fastify plugin that calls `fastify.addSchema()`. **You do NOT need to register them manually.** The plugin works without it. The default export exists only for advanced use cases where you want `$ref` schema resolution in Swagger. If you don't need it, ignore it.
803
+
804
+ ### `sqlapi.config.ts` module warning
805
+
806
+ When running the CLI, Node may show: `Module type of file:///...sqlapi.config.ts is not specified...`. This is harmless — Node detects `export default` and reparses as ESM. To suppress it, ensure your project has `"type": "module"` in `package.json`, or rename the config to `sqlapi.config.mjs`.
807
+
808
+ ### TypeBox version conflicts
809
+
810
+ The CLI generates Schema files that import `Type` and `Static` from `fastify-auto-sqlapi` (not from `@sinclair/typebox` directly). This ensures the consumer uses the same TypeBox version bundled with the plugin, avoiding `[Kind]` type errors from duplicate TypeBox installations.
811
+
812
+ ### Prefix behavior
813
+
814
+ The `prefix` is a standard Fastify register option. Pass it alongside the plugin options:
815
+
816
+ ```typescript
817
+ await app.register(fastifyAutoSqlApi, { DbTables: dbTables, prefix: '/api' });
818
+ ```
819
+
820
+ The plugin internally strips `prefix` before passing options to sub-route plugins, so it is applied only once. Without prefix, routes are at root (`/search/customer`, `/rest/customer/:id`, etc.).
821
+
822
+ ### Dialect differences
823
+
824
+ | | PostgreSQL | MySQL | MariaDB |
825
+ |---|---|---|---|
826
+ | **Identifier quoting** | `"id"` | `` `id` `` | `` `id` `` |
827
+ | **Placeholders** | `$1, $2` | `?, ?` | `?, ?` |
828
+ | **RETURNING** | Yes | No | Yes (10.5+) |
829
+ | **Upsert syntax** | `ON CONFLICT ... DO UPDATE` | `ON DUPLICATE KEY UPDATE` | `ON DUPLICATE KEY UPDATE` |
830
+ | **Auto-increment PK** | Via `RETURNING` | Via `insertId` | Via `RETURNING` |
831
+ | **CLI env vars** | `POSTGRES_*` | `MYSQL_*` | `MYSQL_*` |
832
+ | **CLI introspection** | `pg` | `mysql2` | `mysql2` |
833
+