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,414 @@
1
+ # fastify-auto-sqlapi — Frontend API Usage
2
+
3
+ How to call the auto-generated CRUD endpoints.
4
+
5
+ ## Generated Endpoints
6
+
7
+ The plugin defines routes with these base paths: `/search/`, `/rest/`, `/bulk/`. The prefix from `register()` is prepended by Fastify. For a table `customer` with `prefix: '/auto'`:
8
+
9
+ ```
10
+ POST /auto/search/customer — search with filters in body
11
+ GET /auto/rest/customer/:id — get single record by PK
12
+ POST /auto/rest/customer — insert record (+ secondaries)
13
+ PUT /auto/rest/customer — update record (+ secondaries + deletions)
14
+ DELETE /auto/rest/customer/:id — delete record by PK
15
+ PUT /auto/bulk/customer — bulk upsert (array of items)
16
+ POST /auto/bulk/customer/delete — bulk delete (array of PKs)
17
+ ```
18
+
19
+ Without prefix, routes are at root: `/search/customer`, `/rest/customer/:id`, etc.
20
+
21
+ ---
22
+
23
+ ## POST /search/{tableName}
24
+
25
+ **Important**: Search uses `POST` (not GET) because filters are passed in the request body as JSON.
26
+
27
+ ### Query parameters (all optional)
28
+
29
+ ```
30
+ ?orderBy=name ASC, id DESC
31
+ &page=1&itemsPerPage=20
32
+ &computeMin=id&computeMax=id&computeSum=total&computeAvg=total
33
+ ```
34
+
35
+ - `orderBy` — SQL ORDER BY clause (default: `defaultOrder` from table config)
36
+ - `page` + `itemsPerPage` — pagination (when either is present, response includes `pagination` object; `page` defaults to 1, `itemsPerPage` defaults to 500)
37
+ - `computeMin`, `computeMax`, `computeSum`, `computeAvg` — aggregate a column, returned in `pagination.computed`
38
+
39
+ ### Body (all fields optional, empty `{}` returns all records)
40
+
41
+ ```json
42
+ {
43
+ "filters": { "name": "Mario", "q": "search term" },
44
+ "conditions": [
45
+ { "field": "createdAt", "method": "isGreater", "params": ["2024-01-01"] }
46
+ ],
47
+ "joinFilters": {
48
+ "customer_label_link": { "labelId": 1 }
49
+ },
50
+ "joins": {
51
+ "customer_order": { "filters": { "status": "pending" } }
52
+ },
53
+ "joinGroups": {
54
+ "customer_order": {
55
+ "aggregations": {
56
+ "by": "status",
57
+ "sum": ["total"],
58
+ "min": ["total"],
59
+ "max": ["total"],
60
+ "distinctCount": ["status"]
61
+ },
62
+ "filters": { "status": "active" }
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ ### Filters
69
+
70
+ Pass any schema field as a key in `filters`. The plugin auto-applies `WHERE col = value` for each. Extra filters (defined via `extraFiltersValidation` in the table config) are also accepted but handled by custom logic.
71
+
72
+ ### Conditions (advanced filters)
73
+
74
+ `conditions` allows using any ConditionBuilder method for advanced comparisons. Each condition specifies a `field`, a `method`, and `params` (values after the field).
75
+
76
+ ```json
77
+ {
78
+ "filters": { "status": "active" },
79
+ "conditions": [
80
+ { "field": "total", "method": "isGreater", "params": [100] },
81
+ { "field": "total", "method": "isLess", "params": [500] },
82
+ { "field": "name", "method": "isILike", "params": ["%mario%"] },
83
+ { "field": "createdAt", "method": "isBetween", "params": ["2024-01-01", "2024-12-31"] },
84
+ { "field": "status", "method": "isIn", "params": [["active", "pending"]] },
85
+ { "field": "deletedAt", "method": "isNull", "params": [] }
86
+ ]
87
+ }
88
+ ```
89
+
90
+ **Available methods** (mapped to ConditionBuilder):
91
+
92
+ | Method | SQL | params |
93
+ |---|---|---|
94
+ | `isEqual`, `isNotEqual` | `= $1` / `!= $1` | `[value]` |
95
+ | `isGreater`, `isGreaterOrEqual` | `> $1` / `>= $1` | `[value]` |
96
+ | `isLess`, `isLessOrEqual` | `< $1` / `<= $1` | `[value]` |
97
+ | `isLike`, `isNotLike` | `LIKE $1` | `["%pattern%"]` |
98
+ | `isILike`, `isNotILike` | `ILIKE $1` | `["%pattern%"]` |
99
+ | `isIn`, `isNotIn` | `IN (...)` | `[[val1, val2]]` |
100
+ | `isBetween`, `isNotBetween` | `BETWEEN $1 AND $2` | `[from, to]` |
101
+ | `isNull`, `isNotNull` | `IS NULL` / `IS NOT NULL` | `[]` |
102
+
103
+ Also available: `isNotGreater`, `isNotGreaterOrEqual`, `isNotLess`, `isNotLessOrEqual`.
104
+
105
+ - **Multiple conditions on the same field** — it's an array, so `total > 100 AND total < 500` is natural
106
+ - **Combinable** with `filters` (equality), `joinFilters` (EXISTS), pagination, etc.
107
+ - **Field validation** — field names are validated against the table schema (400 if unknown)
108
+ - **Method validation** — only whitelisted methods are allowed (`raw`, `append` etc. are blocked)
109
+
110
+ ### Join Filters (filter main by related table)
111
+
112
+ `joinFilters` restrict **main table results** based on conditions on a related table. Only tables listed in `allowedReadJoins` are available. Uses `EXISTS` subquery — no duplicate rows, works correctly with pagination.
113
+
114
+ ```json
115
+ {
116
+ "filters": { "name": "Mario" },
117
+ "joinFilters": {
118
+ "customer_label_link": { "labelId": 1 }
119
+ }
120
+ }
121
+ ```
122
+
123
+ SQL generated:
124
+ ```sql
125
+ SELECT * FROM "customer"
126
+ WHERE "name" = $1
127
+ AND EXISTS (SELECT 1 FROM "customer_label_link"
128
+ WHERE "customer_id" = "customer"."id" AND "label_id" = $2)
129
+ ```
130
+
131
+ `joinFilters` support the same filter fields as the related table (schema fields + extra filters defined via `extendedCondition`). Can be combined with `filters`, `joins`, `joinGroups`, and pagination.
132
+
133
+ **Key difference from `joins`**: `joinFilters` filter which main records are returned. `joins` fetch related data for the returned main records. They can be used together.
134
+
135
+ ### Joins
136
+
137
+ Request related table data via `joins`. Only tables listed in `allowedReadJoins` are available. Each join key is the table name, with optional `filters` to narrow the joined results.
138
+
139
+ The join executes as a separate query: `SELECT ... FROM {joinTable} WHERE {fk} IN ({main PKs})`. Results are grouped by FK.
140
+
141
+ ### Join Groups (Aggregations)
142
+
143
+ `joinGroups` compute aggregated data on related tables:
144
+
145
+ ```json
146
+ {
147
+ "joinGroups": {
148
+ "customer_order": {
149
+ "aggregations": {
150
+ "by": "status",
151
+ "sum": ["total"],
152
+ "min": ["total"],
153
+ "max": ["total"],
154
+ "distinctCount": ["status"]
155
+ },
156
+ "filters": { "status": "active" }
157
+ }
158
+ }
159
+ }
160
+ ```
161
+
162
+ - `by` — GROUP BY field
163
+ - `sum`, `min`, `max` — aggregate functions on specified columns
164
+ - `distinctCount` — COUNT(DISTINCT col)
165
+ - `filters` — optional, narrow rows before aggregation
166
+
167
+ ### Ordering
168
+
169
+ Use the `orderBy` query parameter:
170
+
171
+ ```
172
+ ?orderBy=name ASC
173
+ ?orderBy=name ASC, id DESC
174
+ ?orderBy=created_at DESC
175
+ ```
176
+
177
+ If not specified, the table's `defaultOrder` is used.
178
+
179
+ ### Pagination
180
+
181
+ Add `page` or `itemsPerPage` to the query string to enable pagination:
182
+
183
+ ```
184
+ ?page=1&itemsPerPage=20
185
+ ?itemsPerPage=10 # page defaults to 1
186
+ ```
187
+
188
+ 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.
189
+
190
+ ### Computed values (aggregations on main table)
191
+
192
+ ```
193
+ ?computeMin=id&computeMax=id&computeSum=total&computeAvg=total
194
+ ```
195
+
196
+ These compute aggregates on the main table's result set (respecting filters). Results appear in `pagination.computed`. Requires pagination to be active (`page` or `itemsPerPage` set).
197
+
198
+ ### Response
199
+
200
+ ```json
201
+ {
202
+ "table": "customer",
203
+ "main": [{ "id": 1, "name": "Mario", "email": "m@t.it" }],
204
+ "joins": {
205
+ "customer_order": [{ "id": 10, "customerId": 1, "total": 50 }]
206
+ },
207
+ "joinGroups": {
208
+ "customer_order": {
209
+ "sum": { "total": 150 },
210
+ "rows": [{ "by": "pending", "sum_total": 100 }]
211
+ }
212
+ },
213
+ "pagination": {
214
+ "total": 25,
215
+ "pages": 3,
216
+ "computed": { "min": { "id": 1 }, "max": { "id": 100 } },
217
+ "paginator": { "page": 1, "itemsPerPage": 20 }
218
+ }
219
+ }
220
+ ```
221
+
222
+ `joins`, `joinGroups`, `pagination` appear ONLY if requested. A simple search returns just `{ table, main }`.
223
+
224
+ ---
225
+
226
+ ## GET /rest/{tableName}/:id
227
+
228
+ Returns a single record by primary key.
229
+
230
+ **Response (200):**
231
+
232
+ ```json
233
+ { "main": { "id": 1, "name": "Mario", "email": "m@t.it" } }
234
+ ```
235
+
236
+ Returns 404 if not found.
237
+
238
+ ---
239
+
240
+ ## POST /rest/{tableName} (Insert)
241
+
242
+ **Body:**
243
+
244
+ ```json
245
+ {
246
+ "main": { "name": "Mario", "email": "m@t.it" },
247
+ "secondaries": {
248
+ "customer_order": [
249
+ { "total": 50, "status": "pending" }
250
+ ]
251
+ }
252
+ }
253
+ ```
254
+
255
+ - `main` — the record to insert. Omit auto-increment PKs (they are in `excludeFromCreation`).
256
+ - `secondaries` — optional. Related records to insert. Only tables in `allowedWriteJoins` are accepted. FK fields (e.g. `customerId` in orders) are auto-filled from the inserted main record's PK.
257
+
258
+ **Response (201):** PK-only for main and secondaries.
259
+
260
+ ```json
261
+ {
262
+ "main": { "id": 1 },
263
+ "secondaries": {
264
+ "customer_order": [{ "id": 10 }]
265
+ }
266
+ }
267
+ ```
268
+
269
+ ---
270
+
271
+ ## PUT /rest/{tableName} (Update)
272
+
273
+ **Body:**
274
+
275
+ ```json
276
+ {
277
+ "main": { "id": 1, "name": "Mario Updated", "email": "new@t.it" },
278
+ "secondaries": {
279
+ "customer_order": [{ "total": 75, "status": "shipped" }]
280
+ },
281
+ "deletions": {
282
+ "customer_order": [{ "id": 10 }]
283
+ }
284
+ }
285
+ ```
286
+
287
+ - `main` — MUST include the PK. Only changed fields need to be sent (partial update).
288
+ - `secondaries` — optional. New related records to insert (FK auto-filled).
289
+ - `deletions` — optional. Related records to delete (by PK).
290
+
291
+ **Response (200):** PK-only for main, secondaries, and deletions.
292
+
293
+ ```json
294
+ {
295
+ "main": { "id": 1 },
296
+ "secondaries": { "customer_order": [{ "id": 20 }] },
297
+ "deletions": { "customer_order": [{ "id": 10 }] }
298
+ }
299
+ ```
300
+
301
+ ---
302
+
303
+ ## DELETE /rest/{tableName}/:id
304
+
305
+ Deletes a single record by primary key.
306
+
307
+ **Response (200):** PK-only.
308
+
309
+ ```json
310
+ { "main": { "id": 1 } }
311
+ ```
312
+
313
+ 404 if not found.
314
+
315
+ ---
316
+
317
+ ## PUT /bulk/{tableName} (Bulk Upsert)
318
+
319
+ Insert or update multiple records in a single request.
320
+
321
+ **Body:**
322
+
323
+ ```json
324
+ [
325
+ {
326
+ "main": { "name": "Mario", "email": "m@t.it" },
327
+ "secondaries": { "customer_order": [{ "total": 50 }] },
328
+ "deletions": { "customer_order": [{ "id": 99 }] }
329
+ },
330
+ {
331
+ "main": { "name": "Luigi", "email": "l@t.it" }
332
+ }
333
+ ]
334
+ ```
335
+
336
+ All main records are inserted/upserted in a single SQL query. Secondaries and deletions are processed per-item.
337
+
338
+ **Response (200):** Array of `{ main (PK-only), secondaries? (PK-only), deletions? }`.
339
+
340
+ ---
341
+
342
+ ## POST /bulk/{tableName}/delete (Bulk Delete)
343
+
344
+ Delete multiple records by PK.
345
+
346
+ **Body:**
347
+
348
+ ```json
349
+ [{ "id": 1 }, { "id": 2 }, { "id": 3 }]
350
+ ```
351
+
352
+ Each object must contain the PK field. All deletions execute as a single `DELETE WHERE pk IN (...)`.
353
+
354
+ **Response (200):** Array of PK-only objects.
355
+
356
+ ```json
357
+ [{ "main": { "id": 1 } }, { "main": { "id": 2 } }]
358
+ ```
359
+
360
+ ---
361
+
362
+ ## Validation Errors
363
+
364
+ Insert, update, and bulk-upsert endpoints may return **structured field-level validation errors** (400). Two sources:
365
+
366
+ 1. **Schema validation** (TypeBox/Ajv) — type mismatches, missing required fields
367
+ 2. **Custom validation** (`validate` / `validateBulk` on the table) — business rules, cross-entity checks
368
+
369
+ Both return the same response format:
370
+
371
+ ```json
372
+ {
373
+ "statusCode": 400,
374
+ "error": "Bad Request",
375
+ "message": "Validation failed",
376
+ "fields": [
377
+ { "path": "body.main.name", "code": "required", "message": "must have required property 'name'" },
378
+ { "path": "session_period[1].startDate", "code": "overlap", "message": "overlaps with another period" }
379
+ ]
380
+ }
381
+ ```
382
+
383
+ Each entry in `fields`:
384
+ - `path` — the field path (e.g. `body.main.name` for schema errors, `name` or `session_period[1].startDate` for custom validation)
385
+ - `code` — machine-readable error code (e.g. `required`, `type`, `overlap`, `unique`)
386
+ - `message` — human-readable description
387
+
388
+ ### Handling validation errors in the frontend
389
+
390
+ ```typescript
391
+ const response = await fetch('/api/rest/session', {
392
+ method: 'POST',
393
+ headers: { 'Content-Type': 'application/json' },
394
+ body: JSON.stringify({ main: { name: '' }, secondaries: { session_period: periods } }),
395
+ });
396
+
397
+ if (!response.ok) {
398
+ const error = await response.json();
399
+ if (error.fields) {
400
+ // Field-level errors — display inline on the form
401
+ for (const { path, message } of error.fields) {
402
+ setFieldError(path, message);
403
+ }
404
+ }
405
+ }
406
+ ```
407
+
408
+ ---
409
+
410
+ ## Response Conventions
411
+
412
+ - **PK-only responses**: Insert, update, delete, bulk operations return only the primary key fields, not the full record. This is by design for performance and consistency.
413
+ - **camelCase fields**: All request and response fields use camelCase (e.g. `customerId`, not `customer_id`). The plugin converts automatically.
414
+ - **Joins are virtual**: Not SQL JOINs. They execute separate queries and group results by FK. This keeps the main query simple and avoids N+1 issues through batching.
package/README.md CHANGED
@@ -12,6 +12,7 @@ Point it at your database, and get a full REST API with search, pagination, join
12
12
  - **Virtual joins** — fetch related records without complex SQL
13
13
  - **Bulk operations** — batch insert/upsert/delete in single queries
14
14
  - **Multi-tenant** — automatic row-level isolation, zero code in handlers
15
+ - **Validation** — structured field-level validation with cross-entity support
15
16
  - **Hooks** — `beforeInsert`, `beforeUpdate`, `afterInsert` for custom logic
16
17
  - **Swagger UI** — optional, auto-configured from your schemas
17
18
  - **Composable** — register all routes or pick only what you need
@@ -155,10 +156,27 @@ const TableCustomer = defineTable({
155
156
  buildUpsertRule(SchemaCustomer, ['id']),
156
157
  ),
157
158
 
159
+ // Schema overrides (tighten generated schema without editing Schema files)
160
+ schemaOverrides: {
161
+ email: Type.String({ format: 'email' }),
162
+ },
163
+
158
164
  // Multi-tenant isolation
159
165
  tenantScope: { column: 'organization_id' },
160
166
 
161
- // Hooks
167
+ // Validation (runs after schema validation, before hooks)
168
+ validate: async (db, req, main, secondaries) => {
169
+ // Return ValidationError[] — tuple: [field, code] or [field, code, message]
170
+ // message defaults to code if omitted
171
+ if (!main.name) return [['name', 'required']];
172
+ return [];
173
+ },
174
+ validateBulk: async (db, req, items) => {
175
+ // Bulk-upsert only. Called once with all items for cross-item validation.
176
+ return [];
177
+ },
178
+
179
+ // Hooks (runs after validation)
162
180
  beforeInsert: async (db, req, record) => { /* record is snake_case */ },
163
181
  beforeUpdate: async (db, req, fields) => { /* fields is snake_case */ },
164
182
  afterInsert: async (db, req, record, secondaryRecords) => { /* record is camelCase */ },
@@ -54,8 +54,8 @@ async function main() {
54
54
  display('++++++ fastify-auto-sqlapi: generating tables template ++++++', CONSOLE_COLORS.yellow);
55
55
  const config = await loadConfig();
56
56
  const outputDir = path.resolve(process.cwd(), cliArgs.output || config.outputDir);
57
- const schemasDir = path.join(outputDir, 'schemas');
58
- const tablesDir = path.join(outputDir, 'tables');
57
+ const schemasDir = outputDir;
58
+ const tablesDir = outputDir;
59
59
  if (!fs.existsSync(schemasDir)) {
60
60
  error(`Schemas directory not found: ${schemasDir}`);
61
61
  display('Run sqlapi-generate-schema first.', CONSOLE_COLORS.magenta);
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, GetParams, GetResult, DeleteParams, DeleteResult, InsertParams, InsertResult, BulkUpsertItem, BulkUpsertParams, BulkUpsertResult, BulkDeleteParams, BulkDeleteResult, ConditionMethod, SearchCondition, UpdateParams, UpdateResult, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantContext, } from './types.js';
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, 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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"}
@@ -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,CA4BvB"}
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,CAkCvB"}
@@ -19,7 +19,14 @@ export async function introspectTables(connectionString, schema) {
19
19
  pg = require('pg');
20
20
  }
21
21
  catch {
22
- throw new Error('pg is required for PostgreSQL introspection. Install it with: npm install pg');
22
+ try {
23
+ // Fallback: resolve from the package's own node_modules
24
+ const require = createRequire(import.meta.url);
25
+ pg = require('pg');
26
+ }
27
+ catch {
28
+ throw new Error('pg is required for PostgreSQL introspection. Install it with: npm install pg');
29
+ }
23
30
  }
24
31
  const client = new pg.Client({ connectionString });
25
32
  try {
@@ -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,CAiGhG;AAID,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CAqBrE;AAID,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CAoFlE"}
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,CAuHhG;AAID,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CAqBrE;AAID,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CA6FlE"}
@@ -140,6 +140,27 @@ export function generateSingleTableFile(schema, allSchemas) {
140
140
  lines.push(` // allowedReadJoins: [],`);
141
141
  }
142
142
  lines.push(` // upsertMap: buildUpsertRules(buildUpsertRule(Schema, ['${pk}'])),`);
143
+ // schemaOverrides: suggest email format if email field exists, otherwise minLength on first string field
144
+ const emailField = schema.fields.find(f => f.toLowerCase().includes('email'));
145
+ if (emailField) {
146
+ lines.push(` // schemaOverrides: { ${emailField}: Type.String({ format: 'email' }) },`);
147
+ }
148
+ else {
149
+ const stringField = schema.fields.find(f => f !== pk && schema.fieldTypes[f]?.includes('String'));
150
+ if (stringField) {
151
+ lines.push(` // schemaOverrides: { ${stringField}: Type.String({ minLength: 1 }) },`);
152
+ }
153
+ else {
154
+ lines.push(` // schemaOverrides: {},`);
155
+ }
156
+ }
157
+ // validate: use first non-PK field for example
158
+ const exampleField = schema.fields.find(f => f !== pk) || schema.fields[0];
159
+ lines.push(` validate: async (db, req, main, secondaries) => {`);
160
+ lines.push(` const errors = [];`);
161
+ lines.push(` // if (!main.${exampleField}) errors.push(['${exampleField}', 'required']);`);
162
+ lines.push(` return errors;`);
163
+ lines.push(` },`);
143
164
  lines.push(` // beforeInsert: async (db, req, record) => {},`);
144
165
  lines.push(` // beforeUpdate: async (db, req, fields) => {},`);
145
166
  if (childRels.length > 0) {
@@ -223,6 +244,13 @@ export function generateTablesFile(schemas) {
223
244
  lines.push(` // allowedReadJoins: [],`);
224
245
  }
225
246
  lines.push(` // upsertMap: buildUpsertRules(buildUpsertRule(${schema.schemaName}, ['${pk}'])),`);
247
+ lines.push(` // schemaOverrides: {},`);
248
+ const exampleFieldLegacy = schema.fields.find(f => f !== pk) || schema.fields[0];
249
+ lines.push(` validate: async (db, req, main, secondaries) => {`);
250
+ lines.push(` const errors = [];`);
251
+ lines.push(` // if (!main.${exampleFieldLegacy}) errors.push(['${exampleFieldLegacy}', 'required']);`);
252
+ lines.push(` return errors;`);
253
+ lines.push(` },`);
226
254
  lines.push(` // beforeInsert: async (db, req, record) => {},`);
227
255
  lines.push(` // beforeUpdate: async (db, req, fields) => {},`);
228
256
  const childRels = relsByChild.get(schema.schemaName) || [];
@@ -1 +1 @@
1
- {"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/bulk/bulk-upsert.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,gBAAgB,EAChB,gBAAgB,EAGjB,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAuF5F"}
1
+ {"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/bulk/bulk-upsert.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,gBAAgB,EAChB,gBAAgB,EAGjB,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAgG5F"}
@@ -1,6 +1,7 @@
1
1
  import { camelcaseObject, snakecaseRecord } from '../../naming.js';
2
2
  import { removeExcludedFields, processSecondaries, processDeletions } from '../write-helpers.js';
3
3
  import { injectTenantValue, validateTenantFK } from '../../tenant.js';
4
+ import { runValidation, runBulkValidation } from '../validate.js';
4
5
  import { primaryAsString } from '../../../types.js';
5
6
  export async function bulkUpsertEngine(params) {
6
7
  const { db, tableConf, dbTables, request, items, tenant } = params;
@@ -29,13 +30,22 @@ export async function bulkUpsertEngine(params) {
29
30
  }
30
31
  }
31
32
  }
32
- // 2. beforeInsert hook per record
33
+ // 2. Custom validation (receives original camelCase records)
34
+ if (tableConf.validateBulk) {
35
+ await runBulkValidation(db, request, tableConf, items);
36
+ }
37
+ else {
38
+ for (let i = 0; i < items.length; i++) {
39
+ await runValidation(db, request, tableConf, items[i].main, items[i].secondaries);
40
+ }
41
+ }
42
+ // 3. beforeInsert hook per record
33
43
  if (tableConf.beforeInsert) {
34
44
  for (const rec of preparedMains) {
35
45
  await tableConf.beforeInsert(db, request, rec);
36
46
  }
37
47
  }
38
- // 3. Bulk upsert all mains in one query → returns PK-only
48
+ // 4. Bulk upsert all mains in one query → returns PK-only
39
49
  const upsertKeys = tableConf.upsertMap?.get(tableConf.Schema);
40
50
  let pkRows;
41
51
  if (upsertKeys) {
@@ -46,7 +56,7 @@ export async function bulkUpsertEngine(params) {
46
56
  pkRows = await db.bulkInsert(tableConf.Schema.tableName, preparedMains, pkCol);
47
57
  }
48
58
  const pksCamel = pkRows.map((r) => camelcaseObject(r));
49
- // 4. Process secondaries and deletions per item
59
+ // 5. Process secondaries and deletions per item
50
60
  const results = [];
51
61
  for (let i = 0; i < items.length; i++) {
52
62
  const item = items[i];
@@ -64,7 +74,7 @@ export async function bulkUpsertEngine(params) {
64
74
  if (Object.keys(del).length > 0)
65
75
  result.deletions = del;
66
76
  }
67
- // 5. afterInsert hook per item
77
+ // 6. afterInsert hook per item
68
78
  if (tableConf.afterInsert) {
69
79
  await tableConf.afterInsert(db, request, mainForFK, result.secondaries);
70
80
  }
@@ -1 +1 @@
1
- {"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/insert.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAGb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAsE9E"}
1
+ {"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/insert.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAGb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAyE9E"}
@@ -1,6 +1,7 @@
1
1
  import { camelcaseObject, snakecaseRecord } from '../../naming.js';
2
2
  import { removeExcludedFields, processSecondaries } from '../write-helpers.js';
3
3
  import { injectTenantValue, validateTenantFK } from '../../tenant.js';
4
+ import { runValidation } from '../validate.js';
4
5
  import { primaryAsString } from '../../../types.js';
5
6
  export async function insertEngine(params) {
6
7
  const { db, tableConf, dbTables, request, record, secondaries, tenant } = params;
@@ -22,11 +23,13 @@ export async function insertEngine(params) {
22
23
  injectTenantValue(mainRecord, tenant.scope, tenant.ids);
23
24
  }
24
25
  }
25
- // 2. beforeInsert hook
26
+ // 2. Custom validation (receives original camelCase record)
27
+ await runValidation(db, request, tableConf, record, secondaries);
28
+ // 3. beforeInsert hook
26
29
  if (tableConf.beforeInsert) {
27
30
  await tableConf.beforeInsert(db, request, mainRecord);
28
31
  }
29
- // 3. Insert main → returns PK-only
32
+ // 4. Insert main → returns PK-only
30
33
  let pkResult;
31
34
  const upsertKeys = tableConf.upsertMap?.get(tableConf.Schema);
32
35
  if (upsertKeys) {
@@ -37,19 +40,19 @@ export async function insertEngine(params) {
37
40
  pkResult = await db.insert(tableConf.Schema.tableName, mainRecord, pkCol);
38
41
  }
39
42
  const mainPkCamel = camelcaseObject(pkResult);
40
- // 4. Secondaries: need full record for FK auto-fill
43
+ // 5. Secondaries: need full record for FK auto-fill
41
44
  let secondaryResults;
42
45
  if (secondaries && Object.keys(secondaries).length > 0) {
43
46
  // Merge input record + PK for FK auto-fill
44
47
  const mainForFK = { ...camelcaseObject(mainRecord), ...mainPkCamel };
45
48
  secondaryResults = await processSecondaries(db, tableConf, dbTables, mainForFK, secondaries);
46
49
  }
47
- // 5. afterInsert hook
50
+ // 6. afterInsert hook
48
51
  if (tableConf.afterInsert) {
49
52
  const mainForHook = { ...camelcaseObject(mainRecord), ...mainPkCamel };
50
53
  await tableConf.afterInsert(db, request, mainForHook, secondaryResults);
51
54
  }
52
- // 6. Return PK-only
55
+ // 7. Return PK-only
53
56
  const result = { main: mainPkCamel };
54
57
  if (secondaryResults && Object.keys(secondaryResults).length > 0) {
55
58
  result.secondaries = secondaryResults;
@@ -1 +1 @@
1
- {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/update.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAGb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CA6H9E"}
1
+ {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/update.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAGb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAgI9E"}