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.
Files changed (64) hide show
  1. package/AGENTS_BACKEND.md +14 -2
  2. package/AGENTS_FRONTEND.md +7 -5
  3. package/README.md +57 -2
  4. package/dist/bin/generate-tables.js +19 -5
  5. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  6. package/dist/lib/cli/tables-codegen.js +6 -4
  7. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  8. package/dist/lib/engine/bulk/bulk-upsert.js +3 -1
  9. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  10. package/dist/lib/engine/rest/get.js +2 -0
  11. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  12. package/dist/lib/engine/rest/insert.js +3 -1
  13. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  14. package/dist/lib/engine/rest/update.js +5 -2
  15. package/dist/lib/engine/search/search.d.ts.map +1 -1
  16. package/dist/lib/engine/search/search.js +107 -29
  17. package/dist/lib/read-access.d.ts +31 -0
  18. package/dist/lib/read-access.d.ts.map +1 -0
  19. package/dist/lib/read-access.js +53 -0
  20. package/dist/lib/schema/bulk-delete.d.ts +1 -1
  21. package/dist/lib/schema/bulk-delete.d.ts.map +1 -1
  22. package/dist/lib/schema/bulk-delete.js +2 -2
  23. package/dist/lib/schema/bulk-upsert.d.ts +1 -1
  24. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  25. package/dist/lib/schema/bulk-upsert.js +5 -3
  26. package/dist/lib/schema/helpers.d.ts +2 -0
  27. package/dist/lib/schema/helpers.d.ts.map +1 -1
  28. package/dist/lib/schema/helpers.js +3 -2
  29. package/dist/lib/schema/insert.d.ts.map +1 -1
  30. package/dist/lib/schema/insert.js +4 -1
  31. package/dist/lib/schema/search.d.ts.map +1 -1
  32. package/dist/lib/schema/search.js +17 -5
  33. package/dist/lib/schema/update.d.ts.map +1 -1
  34. package/dist/lib/schema/update.js +4 -1
  35. package/dist/lib/sql-api-decorator.d.ts +18 -0
  36. package/dist/lib/sql-api-decorator.d.ts.map +1 -0
  37. package/dist/lib/sql-api-decorator.js +40 -0
  38. package/dist/lib/sql-api.d.ts +2 -0
  39. package/dist/lib/sql-api.d.ts.map +1 -1
  40. package/dist/lib/sql-api.js +1 -0
  41. package/dist/lib/table-helpers.d.ts.map +1 -1
  42. package/dist/lib/table-helpers.js +18 -0
  43. package/dist/lib/tenant.d.ts +22 -0
  44. package/dist/lib/tenant.d.ts.map +1 -1
  45. package/dist/lib/tenant.js +0 -0
  46. package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
  47. package/dist/routes/auto/bulk-delete.routes.js +2 -1
  48. package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
  49. package/dist/routes/auto/bulk-upsert.routes.js +2 -1
  50. package/dist/routes/auto/plugin.d.ts +0 -6
  51. package/dist/routes/auto/plugin.d.ts.map +1 -1
  52. package/dist/routes/auto/plugin.js +2 -28
  53. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  54. package/dist/routes/auto/route-helpers.js +4 -0
  55. package/dist/routes/auto/search.routes.d.ts.map +1 -1
  56. package/dist/routes/auto/search.routes.js +8 -0
  57. package/dist/types/plugin.d.ts +16 -0
  58. package/dist/types/plugin.d.ts.map +1 -1
  59. package/dist/types/plugin.js +4 -1
  60. package/dist/types/search.d.ts +7 -0
  61. package/dist/types/search.d.ts.map +1 -1
  62. package/dist/types/table.d.ts +13 -0
  63. package/dist/types/table.d.ts.map +1 -1
  64. 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 (supports multi: 'name ASC, id DESC')
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
  ---
@@ -35,10 +35,10 @@ Without prefix, routes are at root: `/search/customer`, `/rest/customer/:id`, et
35
35
  ```
36
36
 
37
37
  - `orderBy` — SQL ORDER BY clause (default: `defaultOrder` from table config). Supports dotted notations for joins, see below.
38
- - `page` + `itemsPerPage` — pagination (when either is present, response includes `pagination` object; `page` defaults to 1, `itemsPerPage` defaults to 500)
38
+ - `page` + `itemsPerPage` — pagination (when either is present, response includes `pagination` object; `page` defaults to 1, `itemsPerPage` defaults to 500). `itemsPerPage` is capped server-side at `maxItemsPerPage` (default 1000); sending a larger value returns 400.
39
39
  - `computeMin`, `computeMax`, `computeSum`, `computeAvg` — aggregate a column, returned in `pagination.computed`
40
40
 
41
- ### Body (all fields optional, empty `{}` returns all records)
41
+ ### Body (all fields optional, empty `{}` returns up to `maxItemsPerPage` records)
42
42
 
43
43
  ```json
44
44
  {
@@ -350,7 +350,7 @@ Add `page` or `itemsPerPage` to the query string to enable pagination:
350
350
  ?itemsPerPage=10 # page defaults to 1
351
351
  ```
352
352
 
353
- When either is present, the response includes a `pagination` object with total count, total pages, and the paginator values. `page` defaults to 1, `itemsPerPage` defaults to 500.
353
+ When either is present, the response includes a `pagination` object with total count, total pages, and the paginator values. `page` defaults to 1, `itemsPerPage` defaults to 500. `itemsPerPage` is capped at `maxItemsPerPage` (default 1000) — a larger value returns 400. Even without pagination, a search returns at most `maxItemsPerPage` rows.
354
354
 
355
355
  ### Computed values (aggregations on main table)
356
356
 
@@ -503,6 +503,8 @@ Insert or update multiple records in a single request.
503
503
 
504
504
  All main records are inserted/upserted in a single SQL query. Secondaries and deletions are processed per-item. `secondaries`/`deletions` keys are **aliases**.
505
505
 
506
+ The array is capped at `maxBulkItems` (default 1000); a longer array returns 400 — chunk client-side if you have more.
507
+
506
508
  **Response (200):** Array of `{ main (PK-only), secondaries? (PK-only), deletions? }`.
507
509
 
508
510
  ---
@@ -517,7 +519,7 @@ Delete multiple records by PK.
517
519
  [{ "id": 1 }, { "id": 2 }, { "id": 3 }]
518
520
  ```
519
521
 
520
- Each object must contain the PK field. All deletions execute as a single `DELETE WHERE pk IN (...)`.
522
+ Each object must contain the PK field. All deletions execute as a single `DELETE WHERE pk IN (...)`. The array is capped at `maxBulkItems` (default 1000) — a longer array returns 400.
521
523
 
522
524
  **Response (200):** Array of PK-only objects.
523
525
 
@@ -531,7 +533,7 @@ Each object must contain the PK field. All deletions execute as a single `DELETE
531
533
 
532
534
  Insert, update, and bulk-upsert endpoints may return **structured field-level validation errors** (400). Two sources:
533
535
 
534
- 1. **Schema validation** (TypeBox/Ajv) — type mismatches, missing required fields
536
+ 1. **Schema validation** (TypeBox/Ajv) — type mismatches, missing required fields, and **unknown properties** (write bodies reject any field not in the table Schema — send only real columns, don't include extra keys)
535
537
  2. **Custom validation** (`validate` / `validateBulk` on the table) — business rules, cross-entity checks
536
538
 
537
539
  Both return the same response format:
package/README.md CHANGED
@@ -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 >= 4 (peer dependency)
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 always regenerated to include all schemas.', CONSOLE_COLORS.magenta);
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 = 'Table' + schema.schemaName.replace(/^Schema/, '');
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 dbTablesContent = generateDbTablesIndex(allSchemas);
107
- fs.writeFileSync(dbTablesFile, dbTablesContent);
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,CAwHhG;AAID,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CAqBrE;AAID,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CA6FlE"}
1
+ {"version":3,"file":"tables-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/tables-codegen.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,gBAAgB,EAAE,MAAM,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAID,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAuCpE;AAuCD,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,gBAAgB,EAAE,CAwB3E;AAID,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,GAAG,MAAM,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,CAkF5F"}
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":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAuB,MAAM,mBAAmB,CAAC;AAEnF,wBAAsB,SAAS,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CA6BrE"}
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,CA+D9E"}
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":"AAUA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAEb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAqH9E"}
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":"AAKA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAcb,MAAM,mBAAmB,CAAC;AAmiC3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CAoIvB"}
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"}