fastify-auto-sqlapi 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_BACKEND.md +14 -2
- package/AGENTS_FRONTEND.md +7 -5
- package/README.md +57 -2
- package/dist/bin/generate-tables.js +19 -5
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +6 -4
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +3 -1
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.js +2 -0
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +3 -1
- package/dist/lib/engine/rest/update.d.ts.map +1 -1
- package/dist/lib/engine/rest/update.js +5 -2
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +107 -29
- package/dist/lib/read-access.d.ts +31 -0
- package/dist/lib/read-access.d.ts.map +1 -0
- package/dist/lib/read-access.js +53 -0
- package/dist/lib/schema/bulk-delete.d.ts +1 -1
- package/dist/lib/schema/bulk-delete.d.ts.map +1 -1
- package/dist/lib/schema/bulk-delete.js +2 -2
- package/dist/lib/schema/bulk-upsert.d.ts +1 -1
- package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/schema/bulk-upsert.js +5 -3
- package/dist/lib/schema/helpers.d.ts +2 -0
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +3 -2
- package/dist/lib/schema/insert.d.ts.map +1 -1
- package/dist/lib/schema/insert.js +4 -1
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +17 -5
- package/dist/lib/schema/update.d.ts.map +1 -1
- package/dist/lib/schema/update.js +4 -1
- package/dist/lib/sql-api-decorator.d.ts +18 -0
- package/dist/lib/sql-api-decorator.d.ts.map +1 -0
- package/dist/lib/sql-api-decorator.js +40 -0
- package/dist/lib/sql-api.d.ts +2 -0
- package/dist/lib/sql-api.d.ts.map +1 -1
- package/dist/lib/sql-api.js +1 -0
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +18 -0
- package/dist/lib/tenant.d.ts +22 -0
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +0 -0
- package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
- package/dist/routes/auto/bulk-delete.routes.js +2 -1
- package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
- package/dist/routes/auto/bulk-upsert.routes.js +2 -1
- package/dist/routes/auto/plugin.d.ts +0 -6
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +2 -28
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +4 -0
- package/dist/routes/auto/search.routes.d.ts.map +1 -1
- package/dist/routes/auto/search.routes.js +8 -0
- package/dist/types/plugin.d.ts +16 -0
- package/dist/types/plugin.d.ts.map +1 -1
- package/dist/types/plugin.js +4 -1
- package/dist/types/search.d.ts +7 -0
- package/dist/types/search.d.ts.map +1 -1
- package/dist/types/table.d.ts +13 -0
- package/dist/types/table.d.ts.map +1 -1
- 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
|
|
|
@@ -182,6 +184,8 @@ await app.register(async (instance) => {
|
|
|
182
184
|
|
|
183
185
|
**Note**: After registering the plugin, `app.sqlApi` is available for custom routes — see [SqlApi](#sqlapi--programmatic-high-level-api).
|
|
184
186
|
|
|
187
|
+
**Note**: Granular route plugins create their own `sqlApi` decorator automatically, so they work standalone (no main plugin required). The decorator is scoped to each route plugin: if you also need `app.sqlApi` in your own routes, register the main plugin.
|
|
188
|
+
|
|
185
189
|
---
|
|
186
190
|
|
|
187
191
|
## defineTable() — Complete Reference
|
|
@@ -232,8 +236,12 @@ export const TableCustomer = defineTable({
|
|
|
232
236
|
...exportTableInfo(Schema), // Schema + auto-filter builder
|
|
233
237
|
|
|
234
238
|
// OPTIONAL
|
|
235
|
-
defaultOrder: 'name', // ORDER BY default
|
|
239
|
+
defaultOrder: 'name', // ORDER BY default. camelCase fields are mapped to DB
|
|
240
|
+
// columns (e.g. 'squadIndex' -> "squad_index"); supports
|
|
241
|
+
// multi ('name ASC, id DESC'), computed fields, and raw
|
|
242
|
+
// SQL fragments (unknown tokens pass through unchanged)
|
|
236
243
|
excludeFromCreation: ['id'], // Fields omitted from INSERT — MUST include auto-increment PKs
|
|
244
|
+
readExclude: ['passwordHash'], // Fields hidden from ALL reads (writes unaffected)
|
|
237
245
|
distinctResults: true, // Use SELECT DISTINCT
|
|
238
246
|
|
|
239
247
|
// JOINS — relations to other tables. Alias defaults to joinSchema.tableName —
|
|
@@ -698,6 +706,7 @@ Behavior:
|
|
|
698
706
|
- **Insert**: auto-injects `organization_id` if single tenant; validates if already present (403 if mismatch); 400 if multi-tenant without explicit value
|
|
699
707
|
- **Update**: strips `organization_id` from SET (can't change tenant), adds to WHERE condition
|
|
700
708
|
- **Bulk upsert**: auto-injects on all records
|
|
709
|
+
- **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.
|
|
701
710
|
|
|
702
711
|
### Indirect tenant (via JOIN to parent table)
|
|
703
712
|
|
|
@@ -719,7 +728,7 @@ const TableOrder = defineTable({
|
|
|
719
728
|
Behavior:
|
|
720
729
|
- **Read**: INNER JOIN to parent table + WHERE on parent's tenant column
|
|
721
730
|
- **Insert/Bulk upsert**: validates FK references belong to tenant (1 batch query)
|
|
722
|
-
- **Update**: pre-check via SELECT with INNER JOIN (404 if not found)
|
|
731
|
+
- **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).
|
|
723
732
|
- **Delete**: subquery `DELETE WHERE pk IN (SELECT ... INNER JOIN ... WHERE tenant IN (...))`
|
|
724
733
|
|
|
725
734
|
### Multi-tenant users
|
|
@@ -754,12 +763,15 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
|
|
|
754
763
|
- **camelCase in API, any case in DB**: all request/response fields are camelCase. The plugin converts via `col()` and `colMap`. For snake_case DB columns (default), conversion is automatic. For camelCase DB columns (e.g. betterauth), the CLI generates a `colMap` that preserves the original column names — no conversion needed. Manual schemas without `colMap` fall back to `toUnderscore()`.
|
|
755
764
|
- **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
|
|
756
765
|
- **`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.
|
|
766
|
+
- **`readExclude`**: fields hidden from every read — not projected by search/get, omitted from read response schemas and from this table's default (`*`) join selection when it is a join target. Referencing one from `filters`, `conditions`, `orderBy`, `joinGroup` aggregations or an explicit join `selection` returns 400: hiding a field from output while allowing it to be filtered would leak its value by bisection. **Writes are unaffected** — insert/update/bulk still accept the field, so a column can be writable but never readable (password hash, access token). Primary keys cannot be excluded. Complementary to trimming the Schema, which removes the column from reads *and* writes.
|
|
757
767
|
- **`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.
|
|
758
768
|
- **`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.
|
|
759
769
|
- **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.
|
|
760
770
|
- **`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.
|
|
761
771
|
- **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.
|
|
762
772
|
- **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 } } })`.
|
|
773
|
+
- **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.
|
|
774
|
+
- **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.
|
|
763
775
|
- **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).
|
|
764
776
|
|
|
765
777
|
---
|
package/AGENTS_FRONTEND.md
CHANGED
|
@@ -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
|
|
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
|
@@ -143,8 +143,9 @@ const TableCustomer = defineTable({
|
|
|
143
143
|
...exportTableInfo(SchemaCustomer),
|
|
144
144
|
|
|
145
145
|
// Ordering & filtering
|
|
146
|
-
defaultOrder: 'name', // default ORDER BY
|
|
146
|
+
defaultOrder: 'name', // default ORDER BY (camelCase fields map to DB columns)
|
|
147
147
|
excludeFromCreation: ['id'], // omit from INSERT (e.g. auto-increment)
|
|
148
|
+
readExclude: ['passwordHash'], // hide from all reads (writes unaffected)
|
|
148
149
|
distinctResults: true, // SELECT DISTINCT
|
|
149
150
|
|
|
150
151
|
// Relations — alias defaults to joinSchema.tableName. Override with `{ alias: '...' }`
|
|
@@ -238,6 +239,8 @@ await app.register(fastifyAutoSqlApi, {
|
|
|
238
239
|
swagger: true, // Optional — enable Swagger UI (or pass SwaggerOptions)
|
|
239
240
|
onRequests: [authMiddleware], // Optional — global hooks applied to every route
|
|
240
241
|
getTenantId: (req) => id, // Optional — multi-tenant function
|
|
242
|
+
maxItemsPerPage: 1000, // Optional — cap on search page size (default: 1000)
|
|
243
|
+
maxBulkItems: 1000, // Optional — cap on bulk array length (default: 1000)
|
|
241
244
|
debug: true, // Optional — log all SQL queries and params
|
|
242
245
|
});
|
|
243
246
|
```
|
|
@@ -250,6 +253,8 @@ await app.register(fastifyAutoSqlApi, {
|
|
|
250
253
|
| `swagger` | `boolean \| SwaggerOptions` | No | Enable Swagger UI |
|
|
251
254
|
| `onRequests` | `Function[]` | No | Global auth/middleware hooks |
|
|
252
255
|
| `getTenantId` | `(req) => id \| null` | No | Tenant resolver for multi-tenant |
|
|
256
|
+
| `maxItemsPerPage` | `number` | No | Max search page size, and the row `LIMIT` applied when no paginator is sent (default: `1000`) |
|
|
257
|
+
| `maxBulkItems` | `number` | No | Max number of items accepted by the bulk endpoints (default: `1000`) |
|
|
253
258
|
| `debug` | `boolean` | No | Log all SQL queries to console |
|
|
254
259
|
|
|
255
260
|
For MySQL/MariaDB, register `mysql2/promise` pool instead of `@fastify/postgres` and pass `dialect: 'mysql'` or `'mariadb'`.
|
|
@@ -294,6 +299,43 @@ are always available to your own code.
|
|
|
294
299
|
**3. Multi-tenancy** — `getTenantId` + `tenantScope` filter every query by tenant (see
|
|
295
300
|
[Multi-Tenant](#multi-tenant)).
|
|
296
301
|
|
|
302
|
+
### Request limits
|
|
303
|
+
|
|
304
|
+
Two hard caps bound how much work a single request can trigger (defense against accidental or
|
|
305
|
+
malicious resource exhaustion — relevant precisely because the API is open by default):
|
|
306
|
+
|
|
307
|
+
- **Search result size** — `itemsPerPage` above `maxItemsPerPage` (default `1000`) is rejected
|
|
308
|
+
with `400`. When a search omits pagination entirely, the same value is applied as a `LIMIT`, so
|
|
309
|
+
an empty-body `POST /search/:table` can never dump an entire table.
|
|
310
|
+
- **Bulk array size** — the `PUT /bulk/:table` and `POST /bulk/:table/delete` bodies reject arrays
|
|
311
|
+
longer than `maxBulkItems` (default `1000`) at schema validation.
|
|
312
|
+
|
|
313
|
+
Both are configurable in the plugin options. The programmatic `fastify.sqlApi.*` methods are not
|
|
314
|
+
capped — they run your own trusted code.
|
|
315
|
+
|
|
316
|
+
### Write whitelist
|
|
317
|
+
|
|
318
|
+
Insert/update/bulk-upsert bodies reject **unknown properties** (`additionalProperties: false`):
|
|
319
|
+
only columns present in the generated Schema (as narrowed by `schemaOverrides` /
|
|
320
|
+
`excludeFromCreation`) can be written. An unexpected field is a `400`, never a silently-written
|
|
321
|
+
column — the Schema is the write whitelist. Trim the generated Schema (or use `excludeFromCreation`)
|
|
322
|
+
to keep sensitive columns out of it.
|
|
323
|
+
|
|
324
|
+
### Read visibility
|
|
325
|
+
|
|
326
|
+
`readExclude` hides columns from every read while leaving writes untouched — the case for a
|
|
327
|
+
password hash or an access token: writable, never readable.
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
readExclude: ['accessToken'],
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Excluded fields are not selected by search/get, are omitted from read response schemas and from
|
|
334
|
+
the table's default join selection, and cannot be referenced from `filters`, `conditions`,
|
|
335
|
+
`orderBy`, aggregations or an explicit join `selection` (`400`). That last part is the point:
|
|
336
|
+
letting a hidden field be filtered would leak its value by bisection. Primary keys cannot be
|
|
337
|
+
excluded.
|
|
338
|
+
|
|
297
339
|
## API Reference
|
|
298
340
|
|
|
299
341
|
### POST /search/{table}
|
|
@@ -358,6 +400,8 @@ Search with filters, advanced conditions, pagination, ordering, joins, and aggre
|
|
|
358
400
|
|
|
359
401
|
**Querystring** (optional): `orderBy`, `page`, `itemsPerPage`, `computeMin`, `computeMax`, `computeSum`, `computeAvg`
|
|
360
402
|
|
|
403
|
+
> `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).
|
|
404
|
+
|
|
361
405
|
**Response:**
|
|
362
406
|
|
|
363
407
|
```json
|
|
@@ -501,6 +545,17 @@ defineTable({
|
|
|
501
545
|
|
|
502
546
|
Tables without `tenantScope` are unaffected.
|
|
503
547
|
|
|
548
|
+
### Isolation guarantees on writes
|
|
549
|
+
|
|
550
|
+
Tenant isolation is enforced on write paths, not just reads:
|
|
551
|
+
|
|
552
|
+
- **Upsert conflicts are ownership-checked.** On a tenant-scoped table with an `upsertMap`, an
|
|
553
|
+
upsert (`POST /rest/:table` or `PUT /bulk/:table`) whose conflict key matches a row owned by
|
|
554
|
+
another tenant is rejected with `403` — it cannot overwrite or re-assign that row.
|
|
555
|
+
- **The tenant link cannot be changed to another tenant.** For direct scopes the tenant column is
|
|
556
|
+
stripped from update payloads. For indirect scopes, changing the through-FK (`localField`) is
|
|
557
|
+
allowed only to a value the caller owns; otherwise the update returns `403`.
|
|
558
|
+
|
|
504
559
|
## Swagger
|
|
505
560
|
|
|
506
561
|
Enabled by passing `swagger: true` (or a config object) to the plugin options. Requires `@fastify/swagger` and `@fastify/swagger-ui` as peer dependencies.
|
|
@@ -610,7 +665,7 @@ After registering the plugin, `app.sqlApi` is decorated on the Fastify instance
|
|
|
610
665
|
## Requirements
|
|
611
666
|
|
|
612
667
|
- Node.js >= 18
|
|
613
|
-
- Fastify >=
|
|
668
|
+
- Fastify >= 5 (peer dependency)
|
|
614
669
|
- One of:
|
|
615
670
|
- **PostgreSQL** + `pg` + `@fastify/postgres`
|
|
616
671
|
- **MySQL** / **MariaDB** + `mysql2`
|
|
@@ -20,7 +20,7 @@ function printHelp() {
|
|
|
20
20
|
console.log(' sqlapi-generate-tables --all');
|
|
21
21
|
console.log('');
|
|
22
22
|
display('Table*.ts files are only created if they do not already exist.', CONSOLE_COLORS.magenta);
|
|
23
|
-
display('dbTables.ts is
|
|
23
|
+
display('dbTables.ts is created if missing, indexing the Table*.ts files present on disk.', CONSOLE_COLORS.magenta);
|
|
24
24
|
}
|
|
25
25
|
await runCli('fastify-auto-sqlapi: generating tables template', async () => {
|
|
26
26
|
const cliArgs = parseArgs({
|
|
@@ -84,9 +84,11 @@ await runCli('fastify-auto-sqlapi: generating tables template', async () => {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
+
const tableVarOf = (schema) => 'Table' + schema.schemaName.replace(/^Schema/, '');
|
|
87
88
|
// Generate individual Table*.ts files (skip if already exists)
|
|
89
|
+
const createdTableVars = [];
|
|
88
90
|
for (const schema of targetSchemas) {
|
|
89
|
-
const tableVarName =
|
|
91
|
+
const tableVarName = tableVarOf(schema);
|
|
90
92
|
const tableFile = path.join(tablesDir, `${tableVarName}.ts`);
|
|
91
93
|
if (fs.existsSync(tableFile)) {
|
|
92
94
|
displayAsTableRow(`${tableVarName}.ts`, 'skipped (already exists)', 60, CONSOLE_COLORS.gray);
|
|
@@ -94,17 +96,29 @@ await runCli('fastify-auto-sqlapi: generating tables template', async () => {
|
|
|
94
96
|
else {
|
|
95
97
|
const content = generateSingleTableFile(schema, allSchemas);
|
|
96
98
|
fs.writeFileSync(tableFile, content);
|
|
99
|
+
createdTableVars.push(tableVarName);
|
|
97
100
|
displayAsTableRow(`${tableVarName}.ts`, 'created', 60, CONSOLE_COLORS.green);
|
|
98
101
|
}
|
|
99
102
|
}
|
|
100
|
-
// Generate dbTables.ts only if it does not exist
|
|
103
|
+
// Generate dbTables.ts only if it does not exist. The index must reference only
|
|
104
|
+
// Table*.ts files actually present on disk, otherwise a subset run would emit
|
|
105
|
+
// imports for files that were never generated and the project would not compile.
|
|
101
106
|
const dbTablesFile = path.join(tablesDir, 'dbTables.ts');
|
|
102
107
|
if (fs.existsSync(dbTablesFile)) {
|
|
103
108
|
displayAsTableRow('dbTables.ts', 'skipped (already exists)', 60, CONSOLE_COLORS.gray);
|
|
109
|
+
const indexContent = fs.readFileSync(dbTablesFile, 'utf-8');
|
|
110
|
+
const missing = createdTableVars.filter((v) => !indexContent.includes(v));
|
|
111
|
+
if (missing.length > 0) {
|
|
112
|
+
display(`Remember to add the new tables to dbTables.ts: ${missing.join(', ')}`, CONSOLE_COLORS.yellow);
|
|
113
|
+
}
|
|
104
114
|
}
|
|
105
115
|
else {
|
|
106
|
-
const
|
|
107
|
-
|
|
116
|
+
const existingTableVars = new Set(fs
|
|
117
|
+
.readdirSync(tablesDir)
|
|
118
|
+
.filter((f) => f.startsWith('Table') && f.endsWith('.ts'))
|
|
119
|
+
.map((f) => f.slice(0, -'.ts'.length)));
|
|
120
|
+
const indexSchemas = allSchemas.filter((s) => existingTableVars.has(tableVarOf(s)));
|
|
121
|
+
fs.writeFileSync(dbTablesFile, generateDbTablesIndex(indexSchemas));
|
|
108
122
|
displayAsTableRow('dbTables.ts', 'created', 60, CONSOLE_COLORS.green);
|
|
109
123
|
}
|
|
110
124
|
console.log('');
|
|
@@ -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;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,
|
|
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,CA2HhG;AAID,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CAqBrE;AAID,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CA6FlE"}
|
|
@@ -130,10 +130,10 @@ export function generateSingleTableFile(schema, allSchemas) {
|
|
|
130
130
|
// Imports
|
|
131
131
|
lines.push(`import {defineTable, exportTableInfo, Type} from 'fastify-auto-sqlapi';`);
|
|
132
132
|
lines.push(`import type {ValidationError} from 'fastify-auto-sqlapi';`);
|
|
133
|
-
lines.push(`import {${schema.schemaName} as Schema} from '../schemas/${schema.schemaName}';`);
|
|
133
|
+
lines.push(`import {${schema.schemaName} as Schema} from '../schemas/${schema.schemaName}.js';`);
|
|
134
134
|
// Commented imports for related schemas
|
|
135
135
|
for (const relSchema of relatedSchemas) {
|
|
136
|
-
lines.push(`// import {${relSchema}} from '../schemas/${relSchema}';`);
|
|
136
|
+
lines.push(`// import {${relSchema}} from '../schemas/${relSchema}.js';`);
|
|
137
137
|
}
|
|
138
138
|
lines.push(``);
|
|
139
139
|
// Extra filters placeholder
|
|
@@ -161,6 +161,8 @@ export function generateSingleTableFile(schema, allSchemas) {
|
|
|
161
161
|
else {
|
|
162
162
|
lines.push(` // excludeFromCreation: [],`);
|
|
163
163
|
}
|
|
164
|
+
// Hide columns from every read (writes still accept them, e.g. a password hash)
|
|
165
|
+
lines.push(` // readExclude: [],`);
|
|
164
166
|
if (parentRels.length > 0) {
|
|
165
167
|
lines.push(` // allowedReadJoins: [`);
|
|
166
168
|
for (const rel of parentRels) {
|
|
@@ -213,7 +215,7 @@ export function generateDbTablesIndex(schemas) {
|
|
|
213
215
|
lines.push(``);
|
|
214
216
|
for (const schema of schemas) {
|
|
215
217
|
const tableVarName = 'Table' + schema.schemaName.replace(/^Schema/, '');
|
|
216
|
-
lines.push(`import {${tableVarName}} from './${tableVarName}';`);
|
|
218
|
+
lines.push(`import {${tableVarName}} from './${tableVarName}.js';`);
|
|
217
219
|
}
|
|
218
220
|
lines.push(``);
|
|
219
221
|
lines.push(`export const dbTables: DbTables = {`);
|
|
@@ -245,7 +247,7 @@ export function generateTablesFile(schemas) {
|
|
|
245
247
|
lines.push(``);
|
|
246
248
|
// Schema imports
|
|
247
249
|
for (const schema of schemas) {
|
|
248
|
-
lines.push(`import {${schema.schemaName}} from './${schema.schemaName}';`);
|
|
250
|
+
lines.push(`import {${schema.schemaName}} from './${schema.schemaName}.js';`);
|
|
249
251
|
}
|
|
250
252
|
// Table definitions
|
|
251
253
|
for (const schema of schemas) {
|
|
@@ -1 +1 @@
|
|
|
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,EAEjB,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,
|
|
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,EAEjB,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAoF5F"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { camelcaseObject } from '../../naming.js';
|
|
2
2
|
import { processSecondaries, processDeletions, prepareInsertRecord } from '../write-helpers.js';
|
|
3
|
-
import { enforceTenantOnWrites } from '../../tenant.js';
|
|
3
|
+
import { enforceTenantOnWrites, assertTenantOwnsConflicts } from '../../tenant.js';
|
|
4
4
|
import { runBulkValidation } from '../validate.js';
|
|
5
5
|
import { primaryAsCols } from '../../../types.js';
|
|
6
6
|
export async function bulkUpsertEngine(params) {
|
|
@@ -26,6 +26,8 @@ export async function bulkUpsertEngine(params) {
|
|
|
26
26
|
let pkRows;
|
|
27
27
|
if (upsertKeys) {
|
|
28
28
|
const conflictCols = upsertKeys.map((k) => schema.col(k));
|
|
29
|
+
// Tenant isolation: a conflicting upsert must not overwrite rows owned by another tenant.
|
|
30
|
+
await assertTenantOwnsConflicts(db, tenant, schema.tableName, conflictCols, preparedMains);
|
|
29
31
|
pkRows = await db.bulkInsertOrUpdate(schema.tableName, preparedMains, conflictCols, pkCol);
|
|
30
32
|
}
|
|
31
33
|
else {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"get.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/get.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"get.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/get.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAuB,MAAM,mBAAmB,CAAC;AAEnF,wBAAsB,SAAS,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CA8BrE"}
|
|
@@ -2,6 +2,7 @@ import { ConditionBuilder } from 'node-condition-builder';
|
|
|
2
2
|
import { camelcaseObject } from '../../naming.js';
|
|
3
3
|
import { buildTenantCondition, buildTenantJoin } from '../../tenant.js';
|
|
4
4
|
import { httpError } from '../../errors.js';
|
|
5
|
+
import { readableSelectColumns } from '../../read-access.js';
|
|
5
6
|
import { primaryAsString } from '../../../types.js';
|
|
6
7
|
export async function getEngine(params) {
|
|
7
8
|
const { db, tableConf, id, tenant } = params;
|
|
@@ -19,6 +20,7 @@ export async function getEngine(params) {
|
|
|
19
20
|
const values = cb.getValues();
|
|
20
21
|
const rows = await db.select({
|
|
21
22
|
tableName: tableConf.Schema.tableName,
|
|
23
|
+
columns: readableSelectColumns(tableConf, tableConf.Schema, db),
|
|
22
24
|
where,
|
|
23
25
|
values,
|
|
24
26
|
limit: '1',
|
|
@@ -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,EAEb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,
|
|
1
|
+
{"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/insert.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAEb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAiE9E"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { camelcaseObject } from '../../naming.js';
|
|
2
2
|
import { processSecondaries, prepareInsertRecord } from '../write-helpers.js';
|
|
3
|
-
import { enforceTenantOnWrites } from '../../tenant.js';
|
|
3
|
+
import { enforceTenantOnWrites, assertTenantOwnsConflicts } from '../../tenant.js';
|
|
4
4
|
import { primaryAsCols } from '../../../types.js';
|
|
5
5
|
export async function insertEngine(params) {
|
|
6
6
|
const { db, tableConf, dbTables, request, record, secondaries, tenant } = params;
|
|
@@ -20,6 +20,8 @@ export async function insertEngine(params) {
|
|
|
20
20
|
const upsertKeys = tableConf.upsertMap?.get(schema);
|
|
21
21
|
if (upsertKeys) {
|
|
22
22
|
const conflictCols = upsertKeys.map((k) => schema.col(k));
|
|
23
|
+
// Tenant isolation: a conflicting upsert must not overwrite a row owned by another tenant.
|
|
24
|
+
await assertTenantOwnsConflicts(tx, tenant, schema.tableName, conflictCols, [mainRecord]);
|
|
23
25
|
pkResult = await tx.insertOrUpdate(schema.tableName, mainRecord, conflictCols, pkCol);
|
|
24
26
|
}
|
|
25
27
|
else {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/update.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/update.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAEb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAwH9E"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { snakecaseRecord } from '../../naming.js';
|
|
2
2
|
import { processSecondaries, processDeletions } from '../write-helpers.js';
|
|
3
|
-
import { stripTenantColumn, assertTenantOwnership, buildTenantUpdateExtra, } from '../../tenant.js';
|
|
3
|
+
import { stripTenantColumn, assertTenantOwnership, buildTenantUpdateExtra, enforceTenantFKOnUpdate, } from '../../tenant.js';
|
|
4
4
|
import { runValidation } from '../validate.js';
|
|
5
5
|
import { httpError } from '../../errors.js';
|
|
6
6
|
export async function updateEngine(params) {
|
|
@@ -29,9 +29,12 @@ export async function updateEngine(params) {
|
|
|
29
29
|
const updateFields = { ...snaked };
|
|
30
30
|
for (const c of pkCols)
|
|
31
31
|
delete updateFields[c];
|
|
32
|
-
// 6. Strip tenant column (user cannot change tenant of an existing record)
|
|
32
|
+
// 6. Strip tenant column (user cannot change tenant of an existing record).
|
|
33
|
+
// For indirect scopes the tenant link is the through-FK (localField): it may be changed,
|
|
34
|
+
// but only to another value the caller owns — re-validate the new FK against the tenant.
|
|
33
35
|
if (tenant) {
|
|
34
36
|
stripTenantColumn(updateFields, tenant.scope);
|
|
37
|
+
await enforceTenantFKOnUpdate(db, tenant, updateFields);
|
|
35
38
|
}
|
|
36
39
|
// Steps 7-9 are atomic: a failure in secondaries or deletions rolls back the main
|
|
37
40
|
// update too. Degrades to non-transactional when the adapter has no connect().
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAcb,MAAM,mBAAmB,CAAC;AA+nC3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CA4IvB"}
|