fastify-auto-sqlapi 0.2.2 → 0.2.3
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 +2 -2
- package/AGENTS_FRONTEND.md +2 -2
- package/BREAKING_CHANGES.md +105 -0
- package/CHANGELOG.md +202 -69
- package/README.md +9 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/agent/manifest.d.ts +2 -1
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +11 -3
- package/dist/lib/cli/mysql-introspect.d.ts +28 -0
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +39 -16
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +7 -1
- package/dist/types/plugin.d.ts +21 -0
- package/dist/types/plugin.d.ts.map +1 -1
- package/package.json +1 -1
package/AGENTS_BACKEND.md
CHANGED
|
@@ -1016,7 +1016,7 @@ output, it is the part that does not depend on anyone opening the generated file
|
|
|
1016
1016
|
- **`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. It strips **client-supplied** values only: the payload is sanitized before `beforeInsert` runs, so a value the hook assigns to an excluded field (e.g. a server-generated TEXT id) DOES reach the INSERT. Same for the engine's FK auto-fill on secondaries — an excluded FK column does not suppress it. It is an **ergonomics tool for creation, NOT a field-level security mechanism**: it does not apply to updates — every Schema field is updatable by default, by design. Field-level update rules (`isAdmin`, roles, owner/tenant columns, state fields) are product logic: enforce them in `beforeUpdate` (silent strip) or `validate` (loud 400), or move the privileged transition to a dedicated endpoint and keep it off the auto routes via `operations`.
|
|
1017
1017
|
- **`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. It is **per table and static**: a column hidden here is hidden from every caller, including admins. To keep a column readable on the table's own routes but not through a relation, trim the Schema the *relation* is declared with instead — see the read-grant warning under [buildRelation](#buildrelation-signature).
|
|
1018
1018
|
- **`writeExclude`**: the write-side counterpart of `readExclude` — fields removed from the insert, update and bulk-upsert bodies (main *and* secondaries) and dropped again inside the engines, which `sqlApi.*` reaches without those schemas. Reads are untouched: the field is still projected, filterable and orderable. It exists for the columns the database refuses to be told about. **A `GENERATED ALWAYS AS (<expr>)` column is already covered without any config**: the CLI records it in the Schema's `generatedFields`, and both engines reject a statement that so much as names such a column, so offering it as writable could only turn a client mistake into a `500`. The `writeExclude` key is for the rest — a column a trigger owns, one a migration is about to drop. It is **static and applies to everyone**, admins included; a rule that depends on *who* is asking is product logic and belongs in `beforeUpdate` (silent strip) or `validate` (loud 400), per [ADR 0004](./docs/adr/0004-updates-always-open.md). Not to be confused with `excludeFromCreation`, which whitelists **client input on insert only** and deliberately lets a hook assign the field afterwards — `writeExclude` runs **after** the hooks, because the column refuses the value whoever assigned it. `defineTable` rejects a field that is not in the schema, the primary key (the update body identifies the row with it) and a field that is also in `readExclude` (neither readable nor writable means: remove it from the Schema). See [ADR 0015](./docs/adr/0015-non-writable-columns.md), which narrows [ADR 0004](./docs/adr/0004-updates-always-open.md).
|
|
1019
|
-
- **Database-computed and identity columns are detected by the CLI**: `sqlapi-generate-schema` reads `is_generated` (PostgreSQL) / `
|
|
1019
|
+
- **Database-computed and identity columns are detected by the CLI**: `sqlapi-generate-schema` reads `is_generated` (PostgreSQL) / `GENERATION_EXPRESSION` (MySQL, non-empty only for a real `GENERATED ALWAYS AS (...)` column; `EXTRA` is a fallback matched on `VIRTUAL GENERATED` / `STORED GENERATED`, never on the word alone — MySQL writes `DEFAULT_GENERATED` there for any default *expression*, `DEFAULT CURRENT_TIMESTAMP` included, and such a column is writable) and prints the columns it found, per table, before writing anything — read that output. Generated columns land in the Schema's `generatedFields` and are named in a comment in the generated `Table*.ts`. Separately, `GENERATED ... AS IDENTITY` (PostgreSQL) has no `column_default` to reveal that the database fills it in: it is now flagged like MySQL's `AUTO_INCREMENT`, so the field is Optional and the CLI lists it under `excludeFromCreation` instead of emitting a mandatory primary key the database then refuses.
|
|
1020
1020
|
- **`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.
|
|
1021
1021
|
- **`schemaOverrides`**: narrow an auto-generated field to a stricter TypeBox type (e.g. `{ email: Type.String({ format: 'email' }) }`) where `information_schema` could only report `text`. **Write bodies only** — insert, update, bulk-upsert. An override rules what the API accepts *from now on*, while the rows already stored predate it: a column narrowed to `format: 'email'` today holds whatever was accepted last year, and one made mandatory today is `NULL` on every pre-existing row. Narrowing the response would publish a promise nobody can retroactively make true, so responses and the `filters` map keep the generated type. (Nothing would break at runtime either way — Fastify does not validate responses, and `fast-json-stringify` ignores `format`/`pattern`/`minLength`/`minimum`/`enum` entirely; it acts only on `type`, by coercion.) The override is taken **verbatim**: no `Type.Optional` makes the field mandatory, no `Nullable` rejects an explicit `null`, whatever the column allows — which is how you make a column the DB had to leave nullable (a new column on a populated table) mandatory going forward. The original Schema file is never modified; the primary key is mandatory in the update body (it identifies the row); overrides appear in Swagger.
|
|
1022
1022
|
- **`afterRead`**: the read-side counterpart of `beforeInsert`/`beforeUpdate` — the place to decrypt a column, unpack a blob, rescale a stored unit. Called **once per result set** with camelCase rows mutated **in place** (a hook that calls a remote KMS gets one invocation for the page, not one per row), and awaited. Declared on the table that **owns** the column, and it follows a join: the hook runs wherever that table's rows surface, including through a `joinMultiple`/`joinLeft` declared on another table — `ctx.source` (`'get' | 'search' | 'joinMultiple' | 'joinLeft'`) and `ctx.alias` say which. Not called on `joinGroup` (aggregates, not table rows) nor on an empty result. Rows cannot be added or removed — the pagination `COUNT` has already run, so dropping rows would report a `total` that does not match; narrowing what a caller sees is `filters`/`tenantScope`/`readExclude`. On the main table it runs **after** the join side queries, which correlate on the values the database returned: a hook rewriting the column a relation joins on does not break the correlation. Two things it does not reach, by design: `filters`, `orderBy` and `compute*` run in SQL against the **stored** value (searching an encrypted column searches the ciphertext), and it transforms **values, not types** — the response is serialized against the generated schema, so a hook returning an object where the column is `text` yields `"[object Object]"`, and one returning `12.34` on an `integer` column yields `12`. Decrypting `text`→`text` or rescaling `numeric`→`numeric` is what it is for; if the stored and API types genuinely differ, that is a custom route.
|
|
@@ -1027,7 +1027,7 @@ output, it is the part that does not depend on anyone opening the generated file
|
|
|
1027
1027
|
- **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 } } })`.
|
|
1028
1028
|
- **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.
|
|
1029
1029
|
- **Error responses**: errors the plugin raises itself are `4xx` with messages written for clients (validation, not found, tenant). Anything else — a driver constraint violation, a hook throwing without a `statusCode` — answers a fixed `{"statusCode":500,"error":"Internal Server Error","message":"Internal Server Error"}`: driver messages name tables, columns and constraints, and they stay on the server. **No status code is remapped** (a unique violation is a `500`, not a `409` — that mapping is product logic). The body carries `requestId` (the `reqId` Fastify logs on every line) so the real error is one grep away in the log, where it is also written via `request.log.error` — and attached as `err.cause`, so a consumer `setErrorHandler` can map `err.cause.code === '23505'` to `409` itself. **`exposeDebugInfo: true` is the only way to get the driver detail on the wire** — set it while developing, where a client building against the API cannot always read your log: the `500` then also carries `debugInfo` (`message`, `code`, `constraint`, `detail`, `stack`), additively, so the four fields above keep their production shape. Nothing else flips it: not `debug` (server-side only, and it belongs to whoever registers the plugin), not `NODE_ENV`. Errors thrown by `onRequests` hooks run before the handler and are unaffected.
|
|
1030
|
-
- **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.
|
|
1030
|
+
- **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. Two further caps are **fixed, not configurable**, and bound how much work one search may ask for: at most **100 `conditions`** and **20 comma-separated `orderBy` parts** — each dotted condition and each 3-part `orderBy` token becomes its own correlated subquery, which `maxItemsPerPage` does not bound. Over either limit → 400; the request schema also caps `orderBy` at 1024 characters. Those two live in the **search engine**, not in the route, so they apply to programmatic `sqlApi.search()` as well, together with the `paginator` integer guard. What `sqlApi.*` does *not* inherit are the two configurable caps, which belong to the routes: pass `maxRows` to `sqlApi.search()` for the same `LIMIT` protection, and bound your own arrays before a bulk call.
|
|
1031
1031
|
- **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). The tenant belongs to the *request*, not to the table it addresses: a scoped table is filtered even when reached as a join target or written as a secondary of an unscoped host, so `getTenantId` must tolerate a request with no authenticated user (`req.user?.x ?? null`).
|
|
1032
1032
|
|
|
1033
1033
|
---
|
package/AGENTS_FRONTEND.md
CHANGED
|
@@ -77,7 +77,7 @@ Response (each section present only if requested):
|
|
|
77
77
|
|
|
78
78
|
`orderBy` forms (comma-separable): `field [ASC|DESC]` | `alias.parentField` (joinLeft-eligible alias, adds LEFT JOIN) | `alias.fn.field` (fn: sum|min|max|avg|count|distinctCount — the joinGroup with that fn+field MUST be in the body; rows without children sort as 0). For 3-part orderBy and HAVING-style conditions: `aggregations.by` is allowed only when it equals the correlation FK (else 400); aggregation orderBy is rejected (400) on tables with distinctResults.
|
|
79
79
|
|
|
80
|
-
Condition methods (params arity): `isEqual|isNotEqual|isGreater|isGreaterOrEqual|isLess|isLessOrEqual|isNotGreater|isNotGreaterOrEqual|isNotLess|isNotLessOrEqual` → `[v]`; `isLike|isNotLike|isILike|isNotILike` → `["%p%"]`; `isBetween|isNotBetween` → `[from,to]`; `isIn|isNotIn` → `[[v1,v2]]`; `isNull|isNotNull` → `[]`. Unknown field or method → 400.
|
|
80
|
+
Condition methods (params arity): `isEqual|isNotEqual|isGreater|isGreaterOrEqual|isLess|isLessOrEqual|isNotGreater|isNotGreaterOrEqual|isNotLess|isNotLessOrEqual` → `[v]`; `isLike|isNotLike|isILike|isNotILike` → `["%p%"]`; `isBetween|isNotBetween` → `[from,to]`; `isIn|isNotIn` → `[[v1,v2]]`; `isNull|isNotNull` → `[]`. Unknown field or method → 400; so is a `params` that is not an array or is shorter than the arity above (`isNull`/`isNotNull` take none).
|
|
81
81
|
|
|
82
82
|
## WRITES
|
|
83
83
|
|
|
@@ -108,4 +108,4 @@ Condition methods (params arity): `isEqual|isNotEqual|isGreater|isGreaterOrEqual
|
|
|
108
108
|
"fields": [ { "path": "body.main.name", "code": "required", "message": "must have required property 'name'" } ] }
|
|
109
109
|
```
|
|
110
110
|
|
|
111
|
-
Other: 404 (get/delete not found), 400 `itemsPerPage exceeds the maximum` (lower it), 500 raw DB errors (constraint violations surface unmapped).
|
|
111
|
+
Other: 404 (get/delete not found), 400 `itemsPerPage exceeds the maximum` (lower it), 500 raw DB errors (constraint violations surface unmapped). Fixed complexity caps, per search: **100 `conditions`** per section (top level and each join family) and **20** comma-separated `orderBy` parts (`orderBy` string ≤ 1024 chars) — over either → 400: collapse a long list of equality conditions into one `isIn`. `paginator.page` and `paginator.itemsPerPage` must be integers ≥ 1 — send numbers, not querystring strings.
|
package/BREAKING_CHANGES.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Breaking Changes — Join API redesign
|
|
2
2
|
|
|
3
|
+
*Landed in **0.1.3**.*
|
|
4
|
+
|
|
3
5
|
The join API was rewritten to be explicit, alias-based, and to add a real `LEFT JOIN` mode (`joinLeft`) for N:1 relationships. **No backward compatibility** — old names are rejected with 400. Both backend (table configuration) and frontend (request/response shapes) are affected. This document is the single source of truth for migrating either side.
|
|
4
6
|
|
|
5
7
|
## Why this changed
|
|
@@ -265,6 +267,8 @@ The engine rejects, with `statusCode: 400`, any of:
|
|
|
265
267
|
|
|
266
268
|
# Breaking Change — computed field placeholders use `?` markers
|
|
267
269
|
|
|
270
|
+
*Landed in **0.1.6**.*
|
|
271
|
+
|
|
268
272
|
`ComputedFieldExpr.expr` must now mark each bound value with `?`. The engine assigns the
|
|
269
273
|
placeholder positions. Writing `$1` (or `db.ph(n)`) inside the expression no longer works and
|
|
270
274
|
is rejected with a descriptive error.
|
|
@@ -320,6 +324,8 @@ values in the parameter order.
|
|
|
320
324
|
|
|
321
325
|
# Breaking Change — by-single-id operations disabled for composite primary keys
|
|
322
326
|
|
|
327
|
+
*Landed in **0.1.11**.*
|
|
328
|
+
|
|
323
329
|
Since **0.1.11**, tables whose `primary` is an array of more than one field no longer expose
|
|
324
330
|
the operations that address a record by a single PK value:
|
|
325
331
|
|
|
@@ -385,6 +391,8 @@ endpoints previously behaved correctly for you — they are now gone and you mus
|
|
|
385
391
|
|
|
386
392
|
# Breaking Change — unknown filter keys are rejected
|
|
387
393
|
|
|
394
|
+
*Landed in **0.1.14**.*
|
|
395
|
+
|
|
388
396
|
`filters` keys that match nothing are now a `400`, on the main table and on every join family.
|
|
389
397
|
Previously they were dropped without a word.
|
|
390
398
|
|
|
@@ -458,8 +466,103 @@ delegates to the target's own `filters()` and does run `extendedCondition`.
|
|
|
458
466
|
|
|
459
467
|
---
|
|
460
468
|
|
|
469
|
+
# Breaking Change — request limits, condition arity and tenant scope inside subqueries
|
|
470
|
+
|
|
471
|
+
*Landed in **0.2.0**.*
|
|
472
|
+
|
|
473
|
+
## Why this changed
|
|
474
|
+
|
|
475
|
+
Four ways a single search request could do more than the plugin admitted, and one where the
|
|
476
|
+
declared scope was not the enforced one.
|
|
477
|
+
|
|
478
|
+
- `maxItemsPerPage` bounds the **rows a search returns**, not the work done to find them. Every
|
|
479
|
+
dotted `conditions` entry and every 3-part `orderBy` token becomes its own correlated subquery,
|
|
480
|
+
so an uncapped list turned one request into arbitrarily many.
|
|
481
|
+
- `paginator.page` and `paginator.itemsPerPage` are rendered straight into `LIMIT`/`OFFSET`.
|
|
482
|
+
`Paginator` is a compile-time type only, and `sqlApi.search()` is a documented public API a
|
|
483
|
+
consumer may hand an unvalidated querystring — the integer check has to exist at runtime.
|
|
484
|
+
- `key in map` and `map[key]` walk `Object.prototype`, so `constructor`, `toString`, `valueOf`,
|
|
485
|
+
`hasOwnProperty` and `__proto__` satisfied every "is this a known field" check and reached the
|
|
486
|
+
SQL builders, where they rendered as `undefined` or as a column that does not exist.
|
|
487
|
+
- A condition method was dispatched with whatever `params` carried. A missing or wrongly shaped
|
|
488
|
+
one threw a `TypeError` inside the builder, which the error handler can only report as a 500.
|
|
489
|
+
- `tenantScope` was applied to the main query and to the tables a request addressed directly. The
|
|
490
|
+
subqueries and child writes the same request produced carried no scope, so a scoped table was
|
|
491
|
+
still readable and writable through its own relations.
|
|
492
|
+
|
|
493
|
+
## What changed
|
|
494
|
+
|
|
495
|
+
Every row below answers **400** where it previously did something else.
|
|
496
|
+
|
|
497
|
+
| Input | Rule now | Before |
|
|
498
|
+
|---|---|---|
|
|
499
|
+
| `conditions` | at most **100** entries, per section (top level and each join family) — schema `maxItems` plus an engine backstop | unbounded |
|
|
500
|
+
| `orderBy` | at most **20** comma-separated parts; the request schema also caps the string at **1024** characters | unbounded |
|
|
501
|
+
| `paginator` | `page` and `itemsPerPage` must both be integers `>= 1`, checked before any query runs | any value, rendered into `LIMIT`/`OFFSET` as-is |
|
|
502
|
+
| condition `params` | must be an array; single-value methods need 1 entry, `isBetween`/`isNotBetween` 2, `isIn`/`isNotIn` an array as the first entry | passed to the builder as-is → `TypeError` → 500 |
|
|
503
|
+
| field names | inherited `Object.prototype` keys are not fields, in `filters`, condition fields, `orderBy` and `selectComputed` | resolved as known fields and reached the SQL |
|
|
504
|
+
|
|
505
|
+
The two caps are exported as `MAX_CONDITIONS` and `MAX_ORDER_BY_PARTS`. Both checks live in the
|
|
506
|
+
**engine**, not in the route, which is what covers `sqlApi.search()` — no schema validates a
|
|
507
|
+
programmatic caller. `isNull`/`isNotNull` still take no params, and an explicit `undefined` handed
|
|
508
|
+
to `isIn` remains the documented no-op.
|
|
509
|
+
|
|
510
|
+
And `tenantScope` now follows the request into everything it touches:
|
|
511
|
+
|
|
512
|
+
- **secondaries** — a child row written alongside a main row is scoped and anchored by the child
|
|
513
|
+
table's own `tenantScope`;
|
|
514
|
+
- **`joinLeft`** — the predicate lands in the `ON` clause, so `LEFT` semantics survive;
|
|
515
|
+
- **`joinGroup` aggregation subqueries**, and the 3-part aggregation `orderBy` built on them —
|
|
516
|
+
they counted and summed every tenant's child rows, which disagreed with the scoped
|
|
517
|
+
`result.joinGroup` for the same alias and leaked the existence of the rest.
|
|
518
|
+
|
|
519
|
+
## Who is affected
|
|
520
|
+
|
|
521
|
+
- **A client that sends more than 100 `conditions` or more than 20 `orderBy` parts.** Generated
|
|
522
|
+
filter UIs are the realistic case: a "select all" that emits one condition per row.
|
|
523
|
+
- **A caller that forwards a raw querystring to `sqlApi.search()`.** `itemsPerPage: '10'` is a
|
|
524
|
+
string and is now a 400: parse the querystring before you forward it.
|
|
525
|
+
- **Conditions assembled programmatically.** One built with a missing `params` used to reach the
|
|
526
|
+
builder; it is now refused up front, with the method named.
|
|
527
|
+
- **Any deployment using `tenantScope` with `secondaries`, `joinLeft` or `joinGroup`.**
|
|
528
|
+
Aggregates that used to count across tenants return smaller numbers, and an ordering built on
|
|
529
|
+
them changes with it. A secondary write that used to land in another tenant's rows is now
|
|
530
|
+
refused. This is the fix, not a regression — but a dashboard whose numbers were computed
|
|
531
|
+
cross-tenant will move.
|
|
532
|
+
|
|
533
|
+
Not affected: requests within the caps, a well-formed integer `paginator`, and any deployment
|
|
534
|
+
without `tenantScope`.
|
|
535
|
+
|
|
536
|
+
## Migration
|
|
537
|
+
|
|
538
|
+
Split an oversized `conditions` list rather than raising anything — the caps are not configurable:
|
|
539
|
+
|
|
540
|
+
```typescript
|
|
541
|
+
// Before: one request with 400 conditions
|
|
542
|
+
{ conditions: ids.map((id) => ({ field: 'id', method: 'isEqual', params: [id] })) }
|
|
543
|
+
|
|
544
|
+
// After: one condition, one bound array
|
|
545
|
+
{ conditions: [{ field: 'id', method: 'isIn', params: [ids] }] }
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
Parse a querystring before it reaches `sqlApi.search()`:
|
|
549
|
+
|
|
550
|
+
```typescript
|
|
551
|
+
const paginator = {
|
|
552
|
+
page: Number.parseInt(req.query.page ?? '1', 10),
|
|
553
|
+
itemsPerPage: Number.parseInt(req.query.itemsPerPage ?? '50', 10),
|
|
554
|
+
};
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Then re-read any number your product computes from `joinGroup`: if it was collected under a
|
|
558
|
+
tenant scope, it was counting rows that tenant cannot see, and it changes with this release.
|
|
559
|
+
|
|
560
|
+
---
|
|
561
|
+
|
|
461
562
|
# Breaking Change — the tenant follows the request, not the table it addresses
|
|
462
563
|
|
|
564
|
+
*Landed in **0.2.1**.*
|
|
565
|
+
|
|
463
566
|
`tenantScope` on a table is now enforced even when the request was addressed to a **different**
|
|
464
567
|
table that reaches it through a relation. Previously the tenant was resolved from the addressed
|
|
465
568
|
table alone: if that table declared no `tenantScope`, no predicate was applied anywhere — not to
|
|
@@ -541,6 +644,8 @@ grant, and the scope is only the cap on it.
|
|
|
541
644
|
|
|
542
645
|
# Breaking Change — secondaries run the child table's `beforeInsert`
|
|
543
646
|
|
|
647
|
+
*Landed in **0.2.2**.*
|
|
648
|
+
|
|
544
649
|
## Why this changed
|
|
545
650
|
|
|
546
651
|
`processSecondaries` consulted the child table's configuration for its `excludeFromCreation` and
|
package/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,50 @@ All notable changes to this project are documented here. This project follows
|
|
|
7
7
|
|
|
8
8
|
Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)**.
|
|
9
9
|
|
|
10
|
-
## [
|
|
10
|
+
## [0.2.3]
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **MySQL read every default *expression* as a database-computed column.** The generator
|
|
15
|
+
matched `information_schema.columns.EXTRA` on the substring `generated`, and MySQL writes
|
|
16
|
+
`DEFAULT_GENERATED` there for **any** column whose default is an expression —
|
|
17
|
+
`DEFAULT CURRENT_TIMESTAMP` above all, but also `DEFAULT (UUID())`, `DEFAULT (CURRENT_DATE)`,
|
|
18
|
+
a JSON default. Every one of those columns came out of `sqlapi-generate-schema` listed under
|
|
19
|
+
`generatedFields`, which the runtime strips from the insert, update and bulk-upsert bodies and
|
|
20
|
+
again inside the engines: the column stopped being writable, silently, with no error anywhere —
|
|
21
|
+
a client sending it had the value dropped and read back whatever the default had produced. On a
|
|
22
|
+
schema that timestamps its tables, that is every table. Only `GENERATION_EXPRESSION` separates
|
|
23
|
+
the two cases — it is non-empty for a real `GENERATED ALWAYS AS (<expr>)` column and empty for a
|
|
24
|
+
default expression — and it is now what the flag reads, with the `EXTRA` markers
|
|
25
|
+
(`VIRTUAL GENERATED`, `STORED GENERATED`, `PERSISTENT GENERATED`) kept as a fallback for a
|
|
26
|
+
server or driver that does not return it. MySQL and MariaDB only; PostgreSQL was never
|
|
27
|
+
affected. **Regenerate your schemas** (`sqlapi-generate-schema`) to get the columns back.
|
|
28
|
+
|
|
29
|
+
- **The agent manifest's markdown never rendered `readOnly`.** The JSON manifest has carried the
|
|
30
|
+
flag since 0.2.2, but `/agent/manifest.md` — the rendering built to go into a system prompt —
|
|
31
|
+
dropped it, so the one consumer that reads the markdown, a model, kept putting non-writable
|
|
32
|
+
columns in write payloads and kept having them dropped. The notation now carries `(readOnly)`
|
|
33
|
+
alongside `(writeOnly)`, and the legend explains it.
|
|
34
|
+
|
|
35
|
+
- **`ApiRequest` was documented as exported and was not.** [ADR 0018](./docs/adr/0018-one-seam-to-the-http-framework.md)
|
|
36
|
+
and the 0.2.2 entry below both say the type is exported from the package; `src/index.ts` never
|
|
37
|
+
re-exported it, so a consumer typing a hook or a `getTenantId` against it had to import from a
|
|
38
|
+
`dist/` internal path. It is now part of the public type exports.
|
|
39
|
+
|
|
40
|
+
- **The upsert conflict guard failed open on a `NULL` owner in single-column tenant scopes.**
|
|
41
|
+
`col NOT IN (...)` evaluates to `NULL`, not `TRUE`, when the column is `NULL`, so a conflicting
|
|
42
|
+
row belonging to nobody slipped past the probe and could be claimed and overwritten by any
|
|
43
|
+
tenant's upsert. The single-column branch now reads `(col IS NULL OR col NOT IN (...))` —
|
|
44
|
+
an unowned row is foreign — which is what the `anyOf` branch and the read side already did.
|
|
45
|
+
|
|
46
|
+
- **Documentation corrections.** `AGENTS_BACKEND.md` stated that programmatic `sqlApi.*` calls
|
|
47
|
+
are uncapped. The two *configurable* caps do belong to the routes (`maxItemsPerPage`,
|
|
48
|
+
`maxBulkItems` — `sqlApi.search()` takes `maxRows` for the first), but the complexity caps
|
|
49
|
+
(100 `conditions`, 20 `orderBy` parts) and the `paginator` integer guard live in the engine and
|
|
50
|
+
have always applied to `sqlApi.search()` too. `AGENTS_BACKEND.md` and `README.md` now say which
|
|
51
|
+
cap sits where, and the fixed caps are documented at all.
|
|
52
|
+
|
|
53
|
+
## [0.2.2]
|
|
11
54
|
|
|
12
55
|
### Added
|
|
13
56
|
|
|
@@ -50,6 +93,7 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
|
|
|
50
93
|
`Table*.ts` the generator never overwrites. The CLI also prints the columns it found, per
|
|
51
94
|
table, since a generated source file is not always read. See
|
|
52
95
|
[ADR 0015](./docs/adr/0015-non-writable-columns.md).
|
|
96
|
+
|
|
53
97
|
- **`writeExclude` on `defineTable`** — the write-side counterpart of `readExclude`, for the
|
|
54
98
|
non-writable columns introspection cannot know about (one a trigger owns, one a migration is
|
|
55
99
|
about to drop). Reads are untouched: the field stays projected, filterable and orderable. It
|
|
@@ -57,6 +101,7 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
|
|
|
57
101
|
(ADR 0005): a hook cannot put back a value the database will refuse. `defineTable` rejects an
|
|
58
102
|
unknown field, the primary key, and a field that is also `readExclude`d. It is static and
|
|
59
103
|
applies to everyone — a rule that depends on *who* is asking is still product logic (ADR 0004).
|
|
104
|
+
|
|
60
105
|
- **The agent manifest marks non-writable fields** `readOnly`, alongside the existing
|
|
61
106
|
`writeOnly` for `readExclude`.
|
|
62
107
|
|
|
@@ -74,6 +119,70 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
|
|
|
74
119
|
column a relation joins on cannot break the correlation. `SearchParams`/`GetParams` now carry
|
|
75
120
|
the optional `request`, which `sqlApi.search()`/`sqlApi.get()` forward when given one.
|
|
76
121
|
|
|
122
|
+
### Changed
|
|
123
|
+
|
|
124
|
+
- **The update body requires the primary key**, which identifies the row it updates
|
|
125
|
+
(`PUT /rest/:table` carries it in `main`). The generated PK field is `Optional` — it is
|
|
126
|
+
absent on insert — and that modifier reached the update body unchanged, so a request omitting
|
|
127
|
+
`main.id` built its `WHERE` on `undefined` instead of being rejected. The code comment said
|
|
128
|
+
"PK required" all along.
|
|
129
|
+
|
|
130
|
+
- **A secondary now runs the child table's `beforeInsert`.** `processSecondaries` already applied
|
|
131
|
+
the child's `excludeFromCreation` and its `tenantScope` but skipped its `beforeInsert`, so a
|
|
132
|
+
table whose hook *transformed* a value — encrypting a column, normalising a unit — stored the
|
|
133
|
+
raw client value whenever its rows arrived as another table's children, and the correct one
|
|
134
|
+
through its own route. Nothing failed at write time; the row read back as garbage later. The
|
|
135
|
+
hook runs after the exclusion strip and before the engine's FK auto-fill, which stays
|
|
136
|
+
authoritative, and receives the transaction connection. The child's `validate` and
|
|
137
|
+
`afterInsert` deliberately still do not run — see
|
|
138
|
+
[ADR 0014](./docs/adr/0014-what-follows-a-write-join.md). **Breaking** for a `beforeInsert`
|
|
139
|
+
written assuming it only ever ran on its own table's route.
|
|
140
|
+
|
|
141
|
+
- **The get response schema honours `readExclude`**, like the search response already did; it
|
|
142
|
+
previously advertised fields the engine never selects. It now lives in `lib/schema/get.ts`
|
|
143
|
+
(`GetTableResponse`) instead of inline in the route, which is how it had drifted.
|
|
144
|
+
`schemaOverrides` stays where it was — write bodies only: an override rules what the API
|
|
145
|
+
accepts from now on, while the rows already stored predate it, so narrowing the response
|
|
146
|
+
would promise something no one can retroactively make true.
|
|
147
|
+
|
|
148
|
+
### Fixed
|
|
149
|
+
|
|
150
|
+
- **A `tenantScope` naming a schema field instead of a column now fails at startup.**
|
|
151
|
+
`tenantScope` names real DB columns — the scope is applied to the SQL, after the payload has
|
|
152
|
+
been converted, and `through.foreignField` belongs to a parent table that may have no config of
|
|
153
|
+
its own. Nothing checked it, so writing `organizationId` where the column is `organization_id`
|
|
154
|
+
passed `defineTable` and surfaced as `column "organizationId" does not exist` on every request
|
|
155
|
+
to the table: an opaque `500`, failing closed but saying nothing. All four positions are now
|
|
156
|
+
checked (`column`, each `anyOf` entry, `through.localField`, and `through.foreignField` against
|
|
157
|
+
the parent schema). The check is narrow by design — it fires only when the name *is* a schema
|
|
158
|
+
field whose column differs, so a column absent from the schema raises nothing and a camelCase
|
|
159
|
+
database, where `col()` is the identity, raises nothing either.
|
|
160
|
+
|
|
161
|
+
- **The `no-static-optional-peer` gate had never been able to fire.** `includeOnly: '^src/'` in
|
|
162
|
+
`.dependency-cruiser.mjs` dropped every npm edge from the dependency graph, so the rule meant to
|
|
163
|
+
stop a static import of `pg` / `mysql2` / `@fastify/*` — which breaks installs that use the other
|
|
164
|
+
dialect — matched nothing, ever. The graph now keeps npm dependencies as leaves, and the rule is
|
|
165
|
+
qualified to what its name says: static, value-level imports only, since a type-only import is
|
|
166
|
+
erased at compile time and a dynamic one is what the rule asks for. Both gates were verified by
|
|
167
|
+
introducing a violation and watching them fail.
|
|
168
|
+
|
|
169
|
+
- **`defineTable` never validated `primary`.** A primary key naming a field the schema does not
|
|
170
|
+
have passed startup untouched and became a raw SQL error on the first request — a `500`. It
|
|
171
|
+
now throws at `defineTable` time, naming the field and the operations that need it. The one
|
|
172
|
+
exception is exact: a table exposing only `search` and declaring its own `defaultOrder` never
|
|
173
|
+
reads `primary`, which is the shape generated for a view whose key could not be inferred.
|
|
174
|
+
|
|
175
|
+
- **PostgreSQL identity columns made a table impossible to insert into.**
|
|
176
|
+
`GENERATED ... AS IDENTITY` reports no `column_default`, which is what the generator read to
|
|
177
|
+
decide that the database fills a column in — so the primary key came out **mandatory** in the
|
|
178
|
+
write body while PostgreSQL rejected any explicit value for it (`Column "id" is an identity
|
|
179
|
+
column defined as GENERATED ALWAYS`). Identity columns are now flagged like MySQL's
|
|
180
|
+
`AUTO_INCREMENT`: the field is Optional and the CLI lists it under `excludeFromCreation`.
|
|
181
|
+
|
|
182
|
+
## [0.2.1]
|
|
183
|
+
|
|
184
|
+
### Added
|
|
185
|
+
|
|
77
186
|
- **`tenantScope` over several owner columns — `{ anyOf: [...] }`.** A row that belongs to two
|
|
78
187
|
parties and is visible to either — a message (`sender_id` / `recipient_id`), a transfer
|
|
79
188
|
(`from_account_id` / `to_account_id`), a shift swap — could not be expressed before, leaving
|
|
@@ -91,6 +200,7 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
|
|
|
91
200
|
byte-identical SQL. The three forms do not mix — `defineTable` throws at startup on `anyOf`
|
|
92
201
|
combined with `column` or `through`, on an empty or malformed `anyOf`, and on a scope
|
|
93
202
|
declaring no owner column at all.
|
|
203
|
+
|
|
94
204
|
- `QueryClient.bulkInsertOrUpdate` takes an optional trailing `immutableCols`: columns written
|
|
95
205
|
on insert but left untouched when the statement lands on an existing row. Additive.
|
|
96
206
|
|
|
@@ -111,6 +221,7 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
|
|
|
111
221
|
their own scope emit unchanged SQL. Migration — including the `getTenantId` body that now needs
|
|
112
222
|
to tolerate an unauthenticated request — in
|
|
113
223
|
[BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--the-tenant-follows-the-request-not-the-table-it-addresses).
|
|
224
|
+
|
|
114
225
|
- **Database errors no longer reach clients verbatim.** Any error escaping an auto-generated
|
|
115
226
|
route that the plugin did not raise itself now answers `{"statusCode":500,"error":"Internal
|
|
116
227
|
Server Error","message":"Internal Server Error"}` instead of the driver message, which names
|
|
@@ -131,6 +242,93 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
|
|
|
131
242
|
controlled) rather than the response body (not, and these routes are open by default). See
|
|
132
243
|
[ADR 0013](./docs/adr/0013-sanitized-db-errors.md).
|
|
133
244
|
|
|
245
|
+
## [0.2.0]
|
|
246
|
+
|
|
247
|
+
### Added
|
|
248
|
+
|
|
249
|
+
- `QueryClient.update` and `QueryClient.delete` take an optional trailing `extraCondition`
|
|
250
|
+
(a `ConditionBuilder`) merged into the `WHERE` with its values bound in order — how the tenant
|
|
251
|
+
row guard reaches a write. Additive.
|
|
252
|
+
|
|
253
|
+
### Changed
|
|
254
|
+
|
|
255
|
+
- **BREAKING: `conditions` and `orderBy` are capped.** `maxItemsPerPage` bounds the rows a
|
|
256
|
+
search returns, not the work done to find them: every dotted `conditions` entry and every
|
|
257
|
+
3-part `orderBy` token becomes its own correlated subquery, so an uncapped list turned one
|
|
258
|
+
request into arbitrarily many. `conditions` now takes at most **100** entries per section — the
|
|
259
|
+
top level and each join family — and `orderBy` at most **20** comma-separated parts; over
|
|
260
|
+
either, the answer is a **400**. The request schemas carry the same numbers (`maxItems`, plus a
|
|
261
|
+
1024-character cap on the `orderBy` string), so Swagger advertises exactly what the engine
|
|
262
|
+
enforces. Both are exported as `MAX_CONDITIONS` and `MAX_ORDER_BY_PARTS`, and both are checked
|
|
263
|
+
**in the engine**, which is what covers `sqlApi.search()` — no schema validates a programmatic
|
|
264
|
+
caller. See [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--request-limits-condition-arity-and-tenant-scope-inside-subqueries).
|
|
265
|
+
|
|
266
|
+
- **BREAKING: `paginator` is validated at runtime.** `page` and `itemsPerPage` are rendered
|
|
267
|
+
straight into `LIMIT`/`OFFSET`, and `Paginator` is a compile-time type only: nothing stopped a
|
|
268
|
+
string, a fraction, a zero or a `NaN` — or a SQL fragment, on the documented path where a
|
|
269
|
+
consumer hands `sqlApi.search()` an unvalidated querystring. Both must now be integers `>= 1`,
|
|
270
|
+
checked before any query runs; anything else is a **400**. A numeric *string* (`itemsPerPage:
|
|
271
|
+
'10'`) is rejected too, so a querystring must be parsed before it is forwarded.
|
|
272
|
+
|
|
273
|
+
- **BREAKING: a malformed condition `params` answers 400 instead of 500.** The method was
|
|
274
|
+
dispatched with whatever `params` carried, so a missing or wrongly shaped one threw a
|
|
275
|
+
`TypeError` inside the builder — which the error handler could only report as an
|
|
276
|
+
`Internal Server Error`. Arity is now checked once, at dispatch, for every call site:
|
|
277
|
+
`params` must be an array, the single-value methods need one entry, `isBetween`/`isNotBetween`
|
|
278
|
+
need two, and `isIn`/`isNotIn` need an array as their first entry. `isNull`/`isNotNull` still
|
|
279
|
+
take none, and an explicit `undefined` to `isIn` stays the documented no-op.
|
|
280
|
+
|
|
281
|
+
- **BREAKING: inherited `Object.prototype` keys are no longer field names.** `key in map` and
|
|
282
|
+
`map[key]` walk the prototype chain, so `constructor`, `toString`, `valueOf`, `hasOwnProperty`
|
|
283
|
+
and `__proto__` satisfied every "is this a known field" check and reached the SQL builders,
|
|
284
|
+
where they rendered as `undefined` or as a column that does not exist. Every allowlist and every
|
|
285
|
+
field lookup in the search engine now tests own properties, so an inherited name is simply
|
|
286
|
+
unknown — a **400** in `filters`, condition fields, `orderBy` and `selectComputed`.
|
|
287
|
+
|
|
288
|
+
- **BREAKING: `tenantScope` reaches the subqueries and the child writes of a request.** The
|
|
289
|
+
scope was applied to the main query and to the tables addressed directly, and nowhere else, so
|
|
290
|
+
the same request could still read and write other tenants' rows through its own joins:
|
|
291
|
+
**secondaries** were written without the child table's scope, **`joinLeft`** embedded the parent
|
|
292
|
+
unscoped, and the **`joinGroup` aggregation subqueries** — with the 3-part aggregation
|
|
293
|
+
`orderBy` built on them — counted and summed every tenant's child rows, disagreeing with the
|
|
294
|
+
scoped `result.joinGroup` for the same alias and leaking the existence of the rest. All four
|
|
295
|
+
now carry the scope of the table they touch; on `joinLeft` the predicate lands in the `ON`
|
|
296
|
+
clause, so `LEFT` semantics survive. Aggregates that used to count across tenants return
|
|
297
|
+
smaller numbers, and an ordering built on them changes with it.
|
|
298
|
+
|
|
299
|
+
### Internal
|
|
300
|
+
|
|
301
|
+
- The quality gates became executable and zero-tolerance (eslint, jscpd, knip,
|
|
302
|
+
dependency-cruiser, lefthook, CI), `search.ts` was split into seven layered modules with an
|
|
303
|
+
acyclic graph, and `docs/INDEX.md` plus `npm run find-similar` were added as the anti-duplication
|
|
304
|
+
surface. No runtime behaviour changes with any of it. See
|
|
305
|
+
[ADR 0012](./docs/adr/0012-anti-duplication-tooling.md).
|
|
306
|
+
|
|
307
|
+
## [0.1.14]
|
|
308
|
+
|
|
309
|
+
### Added
|
|
310
|
+
|
|
311
|
+
- **`fields` allowlist on `buildRelation`** — restricts which of the target table's fields are
|
|
312
|
+
reachable through one relation, without hiding them from the table's own routes the way
|
|
313
|
+
`readExclude` would. The relation is declared against a schema narrowed to the list, so
|
|
314
|
+
`selection`, `filters`, `conditions`, `orderBy`, aggregations and the generated
|
|
315
|
+
request/response schemas all reject anything outside it with `400 Unknown field`; the default
|
|
316
|
+
`'*'` selection is spelled out into the allowed columns instead of emitting a real `SELECT *`;
|
|
317
|
+
and computed fields on the target resolve against the narrowed schema, so one reading an
|
|
318
|
+
excluded column is a 400 rather than a way around the list. Declaration-time checks: the list
|
|
319
|
+
must include the join field, and is rejected on `allowedWriteJoins` (write paths resolve
|
|
320
|
+
`upsertMap` by schema identity and must write every column the caller sent). Fail-closed by
|
|
321
|
+
design — see [ADR 0011](./docs/adr/0011-join-fields-allowlist.md) for allowlist vs blocklist.
|
|
322
|
+
|
|
323
|
+
- **[ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md)** — a declared join is a read
|
|
324
|
+
grant: `onRequests` and `operations` are route-level and do not follow a join, so
|
|
325
|
+
`allowedReadJoins` grants read access to the target table under the *host* table's
|
|
326
|
+
authorization. Documents what does cross a join (`readExclude`, `tenantScope`, the relation's
|
|
327
|
+
schema), the trimmed-schema + explicit-`selection` pattern for column-level narrowing, and
|
|
328
|
+
the rejected alternatives (`canBeJoined` hook, `selection` as a ceiling).
|
|
329
|
+
|
|
330
|
+
### Changed
|
|
331
|
+
|
|
134
332
|
- **Unknown `filters` keys are now rejected with `400 Unknown filter field: <key>`** instead
|
|
135
333
|
of being dropped in silence — on the main table and on every join family. The engine only
|
|
136
334
|
ever visited keys matching a schema field, an `extraFilters` entry or a computed field, so a
|
|
@@ -140,64 +338,15 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
|
|
|
140
338
|
covers `sqlApi.search()` as well as the HTTP routes. `undefined` values still mean "filter
|
|
141
339
|
not supplied" and are ignored; an explicit `null` still filters by `IS NULL`. See
|
|
142
340
|
[BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--unknown-filter-keys-are-rejected).
|
|
341
|
+
|
|
143
342
|
- **`joinLeft.<alias>.filters` no longer advertises the parent's `extraFilters`.** They were
|
|
144
343
|
present in the generated body schema and in Swagger, but `buildLeftJoinClauses` never runs
|
|
145
344
|
the target's `extendedCondition` (its column references cannot be qualified with the
|
|
146
345
|
`LEFT JOIN` alias), so the filter applied nothing. They are now rejected with a `400` naming
|
|
147
346
|
the reason and pointing at `joinMustExist` on the same relation.
|
|
148
347
|
|
|
149
|
-
- **The update body requires the primary key**, which identifies the row it updates
|
|
150
|
-
(`PUT /rest/:table` carries it in `main`). The generated PK field is `Optional` — it is
|
|
151
|
-
absent on insert — and that modifier reached the update body unchanged, so a request omitting
|
|
152
|
-
`main.id` built its `WHERE` on `undefined` instead of being rejected. The code comment said
|
|
153
|
-
"PK required" all along.
|
|
154
|
-
- **A secondary now runs the child table's `beforeInsert`.** `processSecondaries` already applied
|
|
155
|
-
the child's `excludeFromCreation` and its `tenantScope` but skipped its `beforeInsert`, so a
|
|
156
|
-
table whose hook *transformed* a value — encrypting a column, normalising a unit — stored the
|
|
157
|
-
raw client value whenever its rows arrived as another table's children, and the correct one
|
|
158
|
-
through its own route. Nothing failed at write time; the row read back as garbage later. The
|
|
159
|
-
hook runs after the exclusion strip and before the engine's FK auto-fill, which stays
|
|
160
|
-
authoritative, and receives the transaction connection. The child's `validate` and
|
|
161
|
-
`afterInsert` deliberately still do not run — see
|
|
162
|
-
[ADR 0014](./docs/adr/0014-what-follows-a-write-join.md). **Breaking** for a `beforeInsert`
|
|
163
|
-
written assuming it only ever ran on its own table's route.
|
|
164
|
-
- **The get response schema honours `readExclude`**, like the search response already did; it
|
|
165
|
-
previously advertised fields the engine never selects. It now lives in `lib/schema/get.ts`
|
|
166
|
-
(`GetTableResponse`) instead of inline in the route, which is how it had drifted.
|
|
167
|
-
`schemaOverrides` stays where it was — write bodies only: an override rules what the API
|
|
168
|
-
accepts from now on, while the rows already stored predate it, so narrowing the response
|
|
169
|
-
would promise something no one can retroactively make true.
|
|
170
|
-
|
|
171
348
|
### Fixed
|
|
172
349
|
|
|
173
|
-
- **A `tenantScope` naming a schema field instead of a column now fails at startup.**
|
|
174
|
-
`tenantScope` names real DB columns — the scope is applied to the SQL, after the payload has
|
|
175
|
-
been converted, and `through.foreignField` belongs to a parent table that may have no config of
|
|
176
|
-
its own. Nothing checked it, so writing `organizationId` where the column is `organization_id`
|
|
177
|
-
passed `defineTable` and surfaced as `column "organizationId" does not exist` on every request
|
|
178
|
-
to the table: an opaque `500`, failing closed but saying nothing. All four positions are now
|
|
179
|
-
checked (`column`, each `anyOf` entry, `through.localField`, and `through.foreignField` against
|
|
180
|
-
the parent schema). The check is narrow by design — it fires only when the name *is* a schema
|
|
181
|
-
field whose column differs, so a column absent from the schema raises nothing and a camelCase
|
|
182
|
-
database, where `col()` is the identity, raises nothing either.
|
|
183
|
-
- **The `no-static-optional-peer` gate had never been able to fire.** `includeOnly: '^src/'` in
|
|
184
|
-
`.dependency-cruiser.mjs` dropped every npm edge from the dependency graph, so the rule meant to
|
|
185
|
-
stop a static import of `pg` / `mysql2` / `@fastify/*` — which breaks installs that use the other
|
|
186
|
-
dialect — matched nothing, ever. The graph now keeps npm dependencies as leaves, and the rule is
|
|
187
|
-
qualified to what its name says: static, value-level imports only, since a type-only import is
|
|
188
|
-
erased at compile time and a dynamic one is what the rule asks for. Both gates were verified by
|
|
189
|
-
introducing a violation and watching them fail.
|
|
190
|
-
- **`defineTable` never validated `primary`.** A primary key naming a field the schema does not
|
|
191
|
-
have passed startup untouched and became a raw SQL error on the first request — a `500`. It
|
|
192
|
-
now throws at `defineTable` time, naming the field and the operations that need it. The one
|
|
193
|
-
exception is exact: a table exposing only `search` and declaring its own `defaultOrder` never
|
|
194
|
-
reads `primary`, which is the shape generated for a view whose key could not be inferred.
|
|
195
|
-
- **PostgreSQL identity columns made a table impossible to insert into.**
|
|
196
|
-
`GENERATED ... AS IDENTITY` reports no `column_default`, which is what the generator read to
|
|
197
|
-
decide that the database fills a column in — so the primary key came out **mandatory** in the
|
|
198
|
-
write body while PostgreSQL rejected any explicit value for it (`Column "id" is an identity
|
|
199
|
-
column defined as GENERATED ALWAYS`). Identity columns are now flagged like MySQL's
|
|
200
|
-
`AUTO_INCREMENT`: the field is Optional and the CLI lists it under `excludeFromCreation`.
|
|
201
350
|
- **Join validation no longer depends on the main result set.** `joinMultiple` / `joinLeft` /
|
|
202
351
|
`joinGroup` skip their side query entirely when the main query matched no rows, so everything
|
|
203
352
|
validated inside it was skipped with it — the same request answered 400 or 200 depending on
|
|
@@ -206,25 +355,9 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
|
|
|
206
355
|
filters are now validated up front, and the selection and aggregation lists are resolved
|
|
207
356
|
before the empty-result bail-out.
|
|
208
357
|
|
|
209
|
-
|
|
358
|
+
## [0.1.13]
|
|
210
359
|
|
|
211
|
-
|
|
212
|
-
reachable through one relation, without hiding them from the table's own routes the way
|
|
213
|
-
`readExclude` would. The relation is declared against a schema narrowed to the list, so
|
|
214
|
-
`selection`, `filters`, `conditions`, `orderBy`, aggregations and the generated
|
|
215
|
-
request/response schemas all reject anything outside it with `400 Unknown field`; the default
|
|
216
|
-
`'*'` selection is spelled out into the allowed columns instead of emitting a real `SELECT *`;
|
|
217
|
-
and computed fields on the target resolve against the narrowed schema, so one reading an
|
|
218
|
-
excluded column is a 400 rather than a way around the list. Declaration-time checks: the list
|
|
219
|
-
must include the join field, and is rejected on `allowedWriteJoins` (write paths resolve
|
|
220
|
-
`upsertMap` by schema identity and must write every column the caller sent). Fail-closed by
|
|
221
|
-
design — see [ADR 0011](./docs/adr/0011-join-fields-allowlist.md) for allowlist vs blocklist.
|
|
222
|
-
- **[ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md)** — a declared join is a read
|
|
223
|
-
grant: `onRequests` and `operations` are route-level and do not follow a join, so
|
|
224
|
-
`allowedReadJoins` grants read access to the target table under the *host* table's
|
|
225
|
-
authorization. Documents what does cross a join (`readExclude`, `tenantScope`, the relation's
|
|
226
|
-
schema), the trimmed-schema + explicit-`selection` pattern for column-level narrowing, and
|
|
227
|
-
the rejected alternatives (`canBeJoined` hook, `selection` as a ceiling).
|
|
360
|
+
### Added
|
|
228
361
|
|
|
229
362
|
- **`excludeTables` config option** — blacklist for `sqlapi-generate-schema`. Tables
|
|
230
363
|
listed in `excludeTables` in `sqlapi.config.ts` (exact names or `*` globs, e.g.
|
package/README.md
CHANGED
|
@@ -62,6 +62,8 @@ And multi-tenancy is one plugin option (`getTenantId: (req) => req.user?.organiz
|
|
|
62
62
|
// → 403 Forbidden — cross-tenant references are rejected server-side
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
> ⚠️ **`getTenantId` returning `null`/`undefined` means _admin_ — no filtering, full cross-tenant access.** So "unauthenticated" and "admin" collapse to the same value: if your auth hook is missing or fails, `req.user` is `undefined` and the `?? null` above serves the request as admin. Nothing fails closed. In production, make `getTenantId` (or the auth hook) **reject when there is no authenticated principal**, and reserve `null` for a real superadmin — see the `getTenantId` option docs.
|
|
66
|
+
|
|
65
67
|
No endpoint written by hand, no resolvers, no query language on the server — and these requests are schema-validated, Swagger-documented, size-capped and tenant-isolated like every other one.
|
|
66
68
|
|
|
67
69
|
## What you get
|
|
@@ -291,9 +293,12 @@ mapping is product logic, for your `setErrorHandler`). Set `exposeDebugInfo: tru
|
|
|
291
293
|
developing to get the driver detail on the wire, additively.
|
|
292
294
|
([ADR 0013](./docs/adr/0013-sanitized-db-errors.md))
|
|
293
295
|
|
|
294
|
-
Two caps are on by default: `maxItemsPerPage` (1000) bounds a search — and is
|
|
295
|
-
`LIMIT` even with no paginator, so an empty-body search cannot dump a table — and
|
|
296
|
-
(1000) bounds a bulk array.
|
|
296
|
+
Two caps are on by default and configurable: `maxItemsPerPage` (1000) bounds a search — and is
|
|
297
|
+
applied as the `LIMIT` even with no paginator, so an empty-body search cannot dump a table — and
|
|
298
|
+
`maxBulkItems` (1000) bounds a bulk array. Two more are fixed and bound the *work* a search asks
|
|
299
|
+
for rather than the rows it returns: 100 `conditions` and 20 `orderBy` parts, each over-limit
|
|
300
|
+
request answered with a 400. Those two are enforced in the engine, so `sqlApi.search()` gets them
|
|
301
|
+
too. Write bodies are `additionalProperties: false`, so the Schema is the
|
|
297
302
|
write whitelist and there is no mass assignment; note that Fastify *strips* an unknown field by
|
|
298
303
|
default rather than rejecting it.
|
|
299
304
|
|
|
@@ -327,3 +332,4 @@ request says exactly what to fix.
|
|
|
327
332
|
## License
|
|
328
333
|
|
|
329
334
|
MIT
|
|
335
|
+
|
package/dist/index.d.ts
CHANGED
|
@@ -34,5 +34,5 @@ export { BulkDeleteTableBody, BulkDeleteTableResponse } from './lib/schema/bulk-
|
|
|
34
34
|
export { default as bulkDeleteRoutes } from './routes/auto/bulk-delete.routes.js';
|
|
35
35
|
export { Type, type Static } from '@sinclair/typebox';
|
|
36
36
|
export { ConditionBuilder, Expression } from 'node-condition-builder';
|
|
37
|
-
export type { SqlApiConfig, ColumnInfo, TableMap, Queryable, TransactionQueryable, SqlResult, DbRecord, DbRecordValue, SelectOptions, SchemaDefinition, JoinDefinition, ITable, TableOperation, DbTables, SqlApiPluginOptions, SwaggerOptions, SearchParams, SearchResult, Paginator, PaginationResult, TableFilterFn, ExtendedConditionFn, FilterRecord, AggregationRequest, JoinGroupRequest, ComputedFieldFn, ComputedFieldContext, ComputedFieldExpr, GetParams, GetResult, DeleteParams, DeleteResult, InsertParams, InsertResult, BulkUpsertItem, BulkUpsertParams, BulkUpsertResult, BulkDeleteParams, BulkDeleteResult, ConditionMethod, SearchCondition, UpdateParams, UpdateResult, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantScopeAnyOf, TenantContext, ValidationError, ValidatorFn, BulkValidatorItem, BulkValidatorFn, } from './types.js';
|
|
37
|
+
export type { ApiRequest, SqlApiConfig, ColumnInfo, TableMap, Queryable, TransactionQueryable, SqlResult, DbRecord, DbRecordValue, SelectOptions, SchemaDefinition, JoinDefinition, ITable, TableOperation, DbTables, SqlApiPluginOptions, SwaggerOptions, SearchParams, SearchResult, Paginator, PaginationResult, TableFilterFn, ExtendedConditionFn, FilterRecord, AggregationRequest, JoinGroupRequest, ComputedFieldFn, ComputedFieldContext, ComputedFieldExpr, GetParams, GetResult, DeleteParams, DeleteResult, InsertParams, InsertResult, BulkUpsertItem, BulkUpsertParams, BulkUpsertResult, BulkDeleteParams, BulkDeleteResult, ConditionMethod, SearchCondition, UpdateParams, UpdateResult, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantScopeAnyOf, TenantContext, ValidationError, ValidatorFn, BulkValidatorItem, BulkValidatorFn, } from './types.js';
|
|
38
38
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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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"}
|
|
@@ -49,7 +49,8 @@ export declare function tableOperations(tableConf: ITable): TableOperation[];
|
|
|
49
49
|
export declare function buildAgentManifest(dbTables: DbTables): AgentManifest;
|
|
50
50
|
/**
|
|
51
51
|
* Compact markdown rendering of the manifest, designed for an LLM system prompt.
|
|
52
|
-
* Notation: `!` required on insert, `?` nullable, `(writeOnly)` never returned by reads
|
|
52
|
+
* Notation: `!` required on insert, `?` nullable, `(writeOnly)` never returned by reads,
|
|
53
|
+
* `(readOnly)` returned by reads and refused by every write.
|
|
53
54
|
*/
|
|
54
55
|
export declare function renderAgentManifestMd(manifest: AgentManifest): string;
|
|
55
56
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../../src/lib/agent/manifest.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAkB,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAQvF,UAAU,aAAa;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mDAAmD;IACnD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2FAA2F;IAC3F,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,qFAAqF;IACrF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,UAAU,YAAY;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,0FAA0F;IAC1F,IAAI,EAAE,KAAK,GAAG,KAAK,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC3B,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IACtC,uFAAuF;IACvF,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,qEAAqE;IACrE,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,SAAS,CAAC,EAAE,YAAY,EAAE,CAAC;IAC3B,oEAAoE;IACpE,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;IAC5B,gFAAgF;IAChF,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;CACvC;AAgCD,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,EAAE,CAInE;AA4DD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,GAAG,aAAa,CAMpE;AAED
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../../src/lib/agent/manifest.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAkB,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAQvF,UAAU,aAAa;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mDAAmD;IACnD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2FAA2F;IAC3F,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,qFAAqF;IACrF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,UAAU,YAAY;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,0FAA0F;IAC1F,IAAI,EAAE,KAAK,GAAG,KAAK,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC3B,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IACtC,uFAAuF;IACvF,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,qEAAqE;IACrE,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,SAAS,CAAC,EAAE,YAAY,EAAE,CAAC;IAC3B,oEAAoE;IACpE,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;IAC5B,gFAAgF;IAChF,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;CACvC;AAgCD,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,EAAE,CAInE;AA4DD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,GAAG,aAAa,CAMpE;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,CAmBrE"}
|
|
@@ -119,13 +119,15 @@ export function buildAgentManifest(dbTables) {
|
|
|
119
119
|
}
|
|
120
120
|
/**
|
|
121
121
|
* Compact markdown rendering of the manifest, designed for an LLM system prompt.
|
|
122
|
-
* Notation: `!` required on insert, `?` nullable, `(writeOnly)` never returned by reads
|
|
122
|
+
* Notation: `!` required on insert, `?` nullable, `(writeOnly)` never returned by reads,
|
|
123
|
+
* `(readOnly)` returned by reads and refused by every write.
|
|
123
124
|
*/
|
|
124
125
|
export function renderAgentManifestMd(manifest) {
|
|
125
126
|
const lines = [
|
|
126
127
|
'# Tables',
|
|
127
128
|
'',
|
|
128
|
-
'Notation: `!`=required on insert, `?`=nullable, `(writeOnly)`=writable, never in reads
|
|
129
|
+
'Notation: `!`=required on insert, `?`=nullable, `(writeOnly)`=writable, never in reads, ' +
|
|
130
|
+
'`(readOnly)`=returned by reads, refused by every write (omit it from write payloads).',
|
|
129
131
|
'serverGenerated fields: omit on insert (values from client are ignored).',
|
|
130
132
|
'readJoins aliases: `1:N` → joinMustExist/joinMultiple/joinGroup; `N:1` → joinLeft.',
|
|
131
133
|
'writeJoins aliases: keys for `secondaries`/`deletions` in writes.',
|
|
@@ -142,7 +144,11 @@ export function renderAgentManifestMd(manifest) {
|
|
|
142
144
|
function namedTypes(map) {
|
|
143
145
|
return Object.entries(map).map(([n, ty]) => `${n}:${ty}`).join(', ');
|
|
144
146
|
}
|
|
145
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* A field's type with its notation suffixes: `!` required, `?` nullable, `(writeOnly)`,
|
|
149
|
+
* `(readOnly)`. The two exclusion suffixes are mutually exclusive — `defineTable` rejects a
|
|
150
|
+
* field that is both — so at most one of them is ever appended.
|
|
151
|
+
*/
|
|
146
152
|
function fieldNotation(f) {
|
|
147
153
|
let s = f.type;
|
|
148
154
|
if (f.required)
|
|
@@ -151,6 +157,8 @@ function fieldNotation(f) {
|
|
|
151
157
|
s += '?';
|
|
152
158
|
if (f.writeOnly)
|
|
153
159
|
s += '(writeOnly)';
|
|
160
|
+
if (f.readOnly)
|
|
161
|
+
s += '(readOnly)';
|
|
154
162
|
return s;
|
|
155
163
|
}
|
|
156
164
|
function renderTableMd(t) {
|
|
@@ -6,6 +6,34 @@ export interface MysqlConnectionConfig {
|
|
|
6
6
|
password: string;
|
|
7
7
|
database: string;
|
|
8
8
|
}
|
|
9
|
+
/** information_schema.columns row. MySQL and MariaDB differ on column-name case. */
|
|
10
|
+
interface MysqlColumnRow {
|
|
11
|
+
TABLE_NAME?: string;
|
|
12
|
+
table_name?: string;
|
|
13
|
+
COLUMN_NAME?: string;
|
|
14
|
+
column_name?: string;
|
|
15
|
+
DATA_TYPE?: string;
|
|
16
|
+
data_type?: string;
|
|
17
|
+
COLUMN_DEFAULT?: string | null;
|
|
18
|
+
column_default?: string | null;
|
|
19
|
+
IS_NULLABLE?: string;
|
|
20
|
+
is_nullable?: string;
|
|
21
|
+
COLUMN_KEY?: string;
|
|
22
|
+
column_key?: string;
|
|
23
|
+
EXTRA?: string;
|
|
24
|
+
extra?: string;
|
|
25
|
+
GENERATION_EXPRESSION?: string | null;
|
|
26
|
+
generation_expression?: string | null;
|
|
27
|
+
TABLE_TYPE?: string;
|
|
28
|
+
table_type?: string;
|
|
29
|
+
}
|
|
9
30
|
export declare function buildMysqlConnectionConfig(): MysqlConnectionConfig;
|
|
31
|
+
/**
|
|
32
|
+
* One `information_schema.columns` row as the generator sees it.
|
|
33
|
+
*
|
|
34
|
+
* @testonly Exported only so unit tests can exercise it directly.
|
|
35
|
+
*/
|
|
36
|
+
export declare function mapMysqlColumnRow(row: MysqlColumnRow): ColumnInfo;
|
|
10
37
|
export declare function introspectMysqlTables(connectionConfig: MysqlConnectionConfig, schema: string): Promise<ColumnInfo[]>;
|
|
38
|
+
export {};
|
|
11
39
|
//# sourceMappingURL=mysql-introspect.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mysql-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/mysql-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;
|
|
1
|
+
{"version":3,"file":"mysql-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/mysql-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAkBD,oFAAoF;AACpF,UAAU,cAAc;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,0BAA0B,IAAI,qBAAqB,CAmBlE;AAyED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,cAAc,GAAG,UAAU,CAejE;AAED,wBAAsB,qBAAqB,CACzC,gBAAgB,EAAE,qBAAqB,EACvC,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CAoBvB"}
|
|
@@ -69,32 +69,55 @@ function mapMysqlType(dataType) {
|
|
|
69
69
|
function extra(row) {
|
|
70
70
|
return String(row.EXTRA || row.extra || '').toLowerCase();
|
|
71
71
|
}
|
|
72
|
+
/** The two `EXTRA` markers that mean a real `GENERATED ALWAYS AS (...)` column. */
|
|
73
|
+
const GENERATED_EXTRA = /\b(?:virtual|stored|persistent) generated\b/;
|
|
74
|
+
/**
|
|
75
|
+
* True only for a column the database computes from an expression — never writable.
|
|
76
|
+
*
|
|
77
|
+
* `EXTRA` cannot be matched on the word 'generated': MySQL writes `DEFAULT_GENERATED` there
|
|
78
|
+
* for any column with a default *expression*, `DEFAULT CURRENT_TIMESTAMP` included, so a
|
|
79
|
+
* substring match takes every such timestamp column out of the write bodies. Only
|
|
80
|
+
* `GENERATION_EXPRESSION` separates the two, and it is non-empty for generated columns alone.
|
|
81
|
+
* `EXTRA` stays as a fallback, matched on the exact markers, for a driver or an older server
|
|
82
|
+
* that does not return the expression.
|
|
83
|
+
*/
|
|
84
|
+
function isGenerated(row) {
|
|
85
|
+
const expression = String(row.GENERATION_EXPRESSION ?? row.generation_expression ?? '').trim();
|
|
86
|
+
return expression !== '' || GENERATED_EXTRA.test(extra(row));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* One `information_schema.columns` row as the generator sees it.
|
|
90
|
+
*
|
|
91
|
+
* @testonly Exported only so unit tests can exercise it directly.
|
|
92
|
+
*/
|
|
93
|
+
export function mapMysqlColumnRow(row) {
|
|
94
|
+
// `||` is kept as-is from the untyped version: this change types the driver, it does
|
|
95
|
+
// not alter behaviour. The trailing fallbacks only satisfy ColumnInfo, which cannot
|
|
96
|
+
// hold `undefined`.
|
|
97
|
+
return {
|
|
98
|
+
table_name: row.TABLE_NAME || row.table_name || '',
|
|
99
|
+
column_name: row.COLUMN_NAME || row.column_name || '',
|
|
100
|
+
udt_name: mapMysqlType(row.DATA_TYPE || row.data_type || ''),
|
|
101
|
+
column_default: row.COLUMN_DEFAULT || row.column_default || null,
|
|
102
|
+
is_nullable: row.IS_NULLABLE || row.is_nullable || '',
|
|
103
|
+
is_primary: (row.COLUMN_KEY || row.column_key) === 'PRI',
|
|
104
|
+
is_auto_increment: extra(row).includes('auto_increment'),
|
|
105
|
+
is_generated: isGenerated(row),
|
|
106
|
+
is_view: (row.TABLE_TYPE || row.table_type) === 'VIEW',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
72
109
|
export async function introspectMysqlTables(connectionConfig, schema) {
|
|
73
110
|
const mysql2 = loadOptionalDependency('mysql2/promise', 'npm install mysql2');
|
|
74
111
|
const connection = await mysql2.createConnection(connectionConfig);
|
|
75
112
|
try {
|
|
76
113
|
const [rows] = await connection.query(`SELECT c.table_name, c.column_name, c.data_type, c.column_default, c.is_nullable,
|
|
77
|
-
c.column_key, c.extra, t.table_type
|
|
114
|
+
c.column_key, c.extra, c.generation_expression, t.table_type
|
|
78
115
|
FROM information_schema.columns c
|
|
79
116
|
JOIN information_schema.tables t
|
|
80
117
|
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
|
81
118
|
WHERE c.table_schema = ?
|
|
82
119
|
ORDER BY c.table_name, c.ordinal_position`, [schema]);
|
|
83
|
-
|
|
84
|
-
// not alter behaviour. The trailing fallbacks only satisfy ColumnInfo, which cannot
|
|
85
|
-
// hold `undefined`.
|
|
86
|
-
return rows.map((row) => ({
|
|
87
|
-
table_name: row.TABLE_NAME || row.table_name || '',
|
|
88
|
-
column_name: row.COLUMN_NAME || row.column_name || '',
|
|
89
|
-
udt_name: mapMysqlType(row.DATA_TYPE || row.data_type || ''),
|
|
90
|
-
column_default: row.COLUMN_DEFAULT || row.column_default || null,
|
|
91
|
-
is_nullable: row.IS_NULLABLE || row.is_nullable || '',
|
|
92
|
-
is_primary: (row.COLUMN_KEY || row.column_key) === 'PRI',
|
|
93
|
-
is_auto_increment: extra(row).includes('auto_increment'),
|
|
94
|
-
// 'VIRTUAL GENERATED' / 'STORED GENERATED': computed from an expression, never writable.
|
|
95
|
-
is_generated: extra(row).includes('generated'),
|
|
96
|
-
is_view: (row.TABLE_TYPE || row.table_type) === 'VIEW',
|
|
97
|
-
}));
|
|
120
|
+
return rows.map(mapMysqlColumnRow);
|
|
98
121
|
}
|
|
99
122
|
finally {
|
|
100
123
|
await connection.end();
|
package/dist/lib/tenant.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,EAAE,gBAAgB,EAAE,KAAK,cAAc,EAAE,KAAK,yBAAyB,EAAE,MAAM,wBAAwB,CAAC;AAE/G,OAAO,KAAK,EAEV,MAAM,EACN,mBAAmB,EACnB,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,EACd,MAAM,aAAa,CAAC;AAwCrB,wBAAsB,aAAa,CACjC,OAAO,EAAE,mBAAmB,EAC5B,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC,CAWpC;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,EAAE,EAAE,WAAW,EACf,KAAK,EAAE,WAAW,EAClB,SAAS,EAAE,QAAQ,EAAE,EACrB,QAAQ,EAAE,MAAM,GACf,gBAAgB,CAgBlB;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,aAAa,GAAG,SAAS,CAI3B;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CACjC,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,QAAQ,EAAE,MAAM,GACf,gBAAgB,GAAG,SAAS,CAoB9B;AAED,wBAAgB,eAAe,CAC7B,EAAE,EAAE,WAAW,EACf,KAAK,EAAE,mBAAmB,EAC1B,aAAa,EAAE,MAAM,GACpB,MAAM,CAMR;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,KAAK,EAAE,gBAAgB,EACvB,SAAS,EAAE,QAAQ,EAAE,GACpB,IAAI,CAYN;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,KAAK,EAAE,iBAAiB,GAAG,mBAAmB,EAC9C,SAAS,EAAE,QAAQ,EAAE,GACpB,IAAI,CAiBN;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,WAAW,EACf,KAAK,EAAE,mBAAmB,EAC1B,SAAS,EAAE,QAAQ,EAAE,EACrB,QAAQ,EAAE,OAAO,EAAE,GAClB,OAAO,CAAC,IAAI,CAAC,CAyBf;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,WAAW,EACf,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,yBAAyB,GAAG,cAAc,EAAE,EACrD,MAAM,EAAE,aAAa,GACpB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,EAAE,CAAA;CAAE,CAwBtC;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,yBAAyB,CAC7C,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EAAE,EACtB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GACjC,OAAO,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/tenant.js
CHANGED
|
@@ -283,10 +283,16 @@ export async function assertTenantOwnsConflicts(db, tenant, tableName, conflictC
|
|
|
283
283
|
};
|
|
284
284
|
let mismatchSql;
|
|
285
285
|
if (tenantCols.length === 1) {
|
|
286
|
-
|
|
286
|
+
const col = tenantCols[0];
|
|
287
|
+
const notIn = `${col} NOT IN (${tenant.ids.map((id) => {
|
|
287
288
|
values.push(id);
|
|
288
289
|
return db.ph(values.length);
|
|
289
290
|
}).join(', ')})`;
|
|
291
|
+
// A NULL owner makes `col NOT IN (...)` return NULL, not TRUE, so an unowned row would slip
|
|
292
|
+
// past the probe and be claimable/overwritten by the upsert. Treat NULL as "not mine" — the
|
|
293
|
+
// same way the multi-column branch (NOT COALESCE(..., FALSE)) and the read side already do —
|
|
294
|
+
// so a NULL-owner conflict row is flagged as foreign.
|
|
295
|
+
mismatchSql = `(${col} IS NULL OR ${notIn})`;
|
|
290
296
|
}
|
|
291
297
|
else {
|
|
292
298
|
// A NULL party makes `IN` return NULL, and `NOT NULL` is not TRUE — a plain negation would
|
package/dist/types/plugin.d.ts
CHANGED
|
@@ -14,6 +14,27 @@ export interface SqlApiPluginOptions {
|
|
|
14
14
|
prefix?: string;
|
|
15
15
|
swagger?: boolean | SwaggerOptions;
|
|
16
16
|
dialect?: DialectName;
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the caller's tenant id(s) for this request. Returning one id (or an array, for a
|
|
19
|
+
* caller who belongs to several tenants) scopes every read and write to rows that tenant owns.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ **Returning `null`/`undefined` means ADMIN — no tenant filtering at all, i.e. full
|
|
22
|
+
* cross-tenant access.** "Unauthenticated" and "admin" therefore collapse to the same value.
|
|
23
|
+
* The tenant identity is read from the request (typically `request.user`, populated by your own
|
|
24
|
+
* auth hook in {@link onRequests}), which this plugin does not set. If that hook is missing,
|
|
25
|
+
* mis-ordered, or fails, `request.user` is `undefined` and the common `?? null` pattern yields
|
|
26
|
+
* `null` → the request is served as admin. Nothing here fails closed.
|
|
27
|
+
*
|
|
28
|
+
* To fail closed, make this callback (or the auth hook) **reject when there is no authenticated
|
|
29
|
+
* principal** instead of returning `null`, and reserve `null` for a real, authorized superadmin:
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* getTenantId: (req) => {
|
|
33
|
+
* if (!req.user) throw app.httpErrors.unauthorized(); // no principal → deny, never admin
|
|
34
|
+
* return req.user.isSuperadmin ? null : req.user.organizationId;
|
|
35
|
+
* }
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
17
38
|
getTenantId?: (request: ApiRequest) => TenantId | TenantId[] | null | undefined | Promise<TenantId | TenantId[] | null | undefined>;
|
|
18
39
|
debug?: boolean;
|
|
19
40
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/types/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,QAAQ,CAAC;IACnB,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,KAAK,OAAO,CAAC,IAAI,GAAG,QAAQ,CAAC,CAAC,EAAE,CAAC;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,GAAG,cAAc,CAAC;IACnC,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,UAAU,KAAK,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,GAC3E,OAAO,CAAC,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACtD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;;;;;;;;;;;;OAiBG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,qGAAqG;AACrG,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,8EAA8E;AAC9E,eAAO,MAAM,sBAAsB,OAAO,CAAC"}
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/types/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,QAAQ,CAAC;IACnB,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,KAAK,OAAO,CAAC,IAAI,GAAG,QAAQ,CAAC,CAAC,EAAE,CAAC;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,GAAG,cAAc,CAAC;IACnC,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,UAAU,KAAK,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,GAC3E,OAAO,CAAC,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACtD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;;;;;;;;;;;;OAiBG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,qGAAqG;AACrG,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,8EAA8E;AAC9E,eAAO,MAAM,sBAAsB,OAAO,CAAC"}
|