fastify-auto-sqlapi 0.1.12 → 0.1.14
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 +28 -1
- package/BREAKING_CHANGES.md +75 -0
- package/CHANGELOG.md +55 -0
- package/README.md +140 -42
- package/dist/bin/generate-schema.js +9 -1
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +2 -0
- package/dist/lib/cli/schema-codegen.d.ts +7 -0
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +25 -0
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +57 -14
- package/dist/lib/read-access.d.ts +25 -0
- package/dist/lib/read-access.d.ts.map +1 -1
- package/dist/lib/read-access.js +47 -0
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +31 -11
- package/dist/lib/table-helpers.d.ts +16 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +55 -1
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/cli.d.ts.map +1 -1
- package/dist/types/join.d.ts +9 -0
- package/dist/types/join.d.ts.map +1 -1
- package/docs/adr/0009-schema-exclusion-config-blacklist.md +47 -0
- package/docs/adr/0010-joins-do-not-run-route-guards.md +100 -0
- package/docs/adr/0011-join-fields-allowlist.md +95 -0
- package/docs/adr/README.md +3 -0
- package/package.json +1 -1
package/AGENTS_BACKEND.md
CHANGED
|
@@ -357,6 +357,8 @@ buildRelation(mainSchema, mainField, joinSchema, joinField, options?)
|
|
|
357
357
|
// unique?: boolean; // default false.
|
|
358
358
|
// // false → 1:N (child→main). Allowed in joinMustExist/joinMultiple/joinGroup.
|
|
359
359
|
// // true → N:1 (parent→main). Allowed in joinLeft only.
|
|
360
|
+
// fields?: string[]; // allowlist of the target's fields reachable through this relation.
|
|
361
|
+
// // Read joins only. Must include joinField. Everything else is 400.
|
|
360
362
|
// }
|
|
361
363
|
```
|
|
362
364
|
|
|
@@ -367,6 +369,27 @@ buildRelation(mainSchema, mainField, joinSchema, joinField, options?)
|
|
|
367
369
|
|
|
368
370
|
**When to declare an explicit `alias`**: only when (a) you join the same table multiple times in the same `allowedReadJoins`/`allowedWriteJoins`, or (b) you want a friendlier name in the public API surface than the SQL table name (e.g. `'orders'` instead of `'customer_order'`). Otherwise omit it — the default is `joinSchema.tableName`.
|
|
369
371
|
|
|
372
|
+
**⚠️ A relation in `allowedReadJoins` is a read grant on the target table.** The join runs inside the query of the *host* table's route, so the target's `onRequests` never fire and its `operations` whitelist does not apply — a table with `operations: []` is still fully readable through any relation pointing at it. What does cross a join: `readExclude`, `tenantScope`, and the schema the relation was declared with.
|
|
373
|
+
|
|
374
|
+
To expose a table broadly but keep some of its columns narrow, give the relation a `fields` allowlist:
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
allowedReadJoins: [
|
|
378
|
+
buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true, fields: ['id', 'name'] }),
|
|
379
|
+
],
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
The relation is declared against a schema narrowed to that list, so every read surface validates against it — `selection`, `filters`, `conditions`, `orderBy`, aggregations, and the generated request/response schemas. A field outside the list is `400 Unknown field` everywhere, not just missing from the projection, and the default `'*'` selection is spelled out into the allowed columns rather than emitting a real `SELECT *`.
|
|
383
|
+
|
|
384
|
+
Rules, all enforced at declaration time:
|
|
385
|
+
|
|
386
|
+
- **Must include the join field** — it is what correlates the fetched rows with the main ones.
|
|
387
|
+
- **Not allowed on `allowedWriteJoins`** — the allowlist restricts reading only; write paths resolve `upsertMap` by schema identity and must write every column the caller sent. Declare two relations if the same pair is joined for reading and for writing.
|
|
388
|
+
- **Computed fields on the target resolve against the narrowed schema** — one that reads a field outside the list is a `400`, so a computed cannot be used to reach past the allowlist. One that stays inside keeps working.
|
|
389
|
+
- **Fail-closed** — a column added to the target later is not reachable through the relation until it is added to `fields`.
|
|
390
|
+
|
|
391
|
+
`fields` is not a replacement for `readExclude` (which hides a column from the owning table's own routes too). See [ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md) and [ADR 0011](./docs/adr/0011-join-fields-allowlist.md).
|
|
392
|
+
|
|
370
393
|
**Choosing `unique`**: if `joinField` is the PK (or part of the composite PK) of `joinSchema`, the relation is N:1 — you almost certainly want `unique: true` so the alias is usable in `joinLeft`.
|
|
371
394
|
|
|
372
395
|
**Owned child tables (translations, `*_info` details): use a writeJoin, not a standalone table.** A table that only exists as a child of a parent — e.g. `product_info` with composite PK `(product_id, lang)` — should be an `allowedWriteJoins` on the parent, not its own `DbTables` entry. The engine auto-fills the FK (`product_id`); add it to `upsertMap` (conflict key = the composite PK) to upsert children passing only their own fields. Avoids redundant endpoints/validators.
|
|
@@ -431,6 +454,10 @@ export const TableCustomer = defineTable({
|
|
|
431
454
|
|
|
432
455
|
`extraFilters` accepts either a `Type.Object({...})` or a plain `Record<string, TSchema>`. Extra filter fields appear in Swagger but are NOT auto-applied as `WHERE col = value` — they are handled exclusively by the `extendedCondition` callback. The `filters` parameter in the callback is fully typed with autocomplete for all schema fields + extra filter keys.
|
|
433
456
|
|
|
457
|
+
**Not available on `joinLeft`.** `extendedCondition` writes its own column references, which cannot be qualified with the `LEFT JOIN` alias the parent gets there, so `buildLeftJoinClauses` handles schema and computed fields only. The generated body schema does not advertise the parent's `extraFilters` under `joinLeft.<alias>.filters`, and sending one is a `400` rather than a silent no-op. Use `joinMustExist` on the same relation when you need an extra filter on the parent.
|
|
458
|
+
|
|
459
|
+
**Unknown filter keys are a `400`** (`Unknown filter field: <key>`), on the main table and on every join. A filter that does not match a schema field, an `extraFilters` key or a computed field is rejected instead of dropped: silently ignoring it would return *more* rows than the caller asked for. The check lives in the engine, so it applies to `sqlApi.search()` too, and it runs before any query — the outcome never depends on whether the main query matched rows.
|
|
460
|
+
|
|
434
461
|
---
|
|
435
462
|
|
|
436
463
|
## Computed Fields (extension system)
|
|
@@ -777,7 +804,7 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
|
|
|
777
804
|
- **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
|
|
778
805
|
- **Nullable columns use `Nullable(T)` (type-array form)**: the generator emits `Type.Optional(Nullable(T))` for nullable columns — `Nullable` (exported by the package) produces `{ type: ['integer', 'null'] }`. Do NOT replace it with `Type.Union([T, Type.Null()])`: under Fastify's default Ajv `coerceTypes` a union corrupts values through its branches (`null` → `0` with the Null branch last, `0`/`""` → `null` with it first), while the type array validates and serializes NULL correctly. A bare `Type.Optional(T)` is also wrong: NULL serializes as `0`/`""`. In `filters`, an explicit `null` on a schema field builds `WHERE col IS NULL`.
|
|
779
806
|
- **`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`.
|
|
780
|
-
- **`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.
|
|
807
|
+
- **`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).
|
|
781
808
|
- **`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.
|
|
782
809
|
- **`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.
|
|
783
810
|
- **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.
|
package/BREAKING_CHANGES.md
CHANGED
|
@@ -380,3 +380,78 @@ endpoints previously behaved correctly for you — they are now gone and you mus
|
|
|
380
380
|
- **First PK column is truly unique on its own?** Then the composite declaration was
|
|
381
381
|
redundant: declare `primary: 'id'` (single) and keep the second column as a plain field —
|
|
382
382
|
every by-id operation comes back.
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
# Breaking Change — unknown filter keys are rejected
|
|
387
|
+
|
|
388
|
+
`filters` keys that match nothing are now a `400`, on the main table and on every join family.
|
|
389
|
+
Previously they were dropped without a word.
|
|
390
|
+
|
|
391
|
+
## Why this changed
|
|
392
|
+
|
|
393
|
+
The engine only ever visited filter keys matching a schema field, an `extraFilters` entry or a
|
|
394
|
+
computed field. Anything else — a typo, a renamed column, a field that exists on the table but
|
|
395
|
+
not on the trimmed Schema a relation was declared with — was never looked at, and the query ran
|
|
396
|
+
as if the filter had not been sent.
|
|
397
|
+
|
|
398
|
+
That fails in the dangerous direction. A rejected filter returns *more* rows than the caller
|
|
399
|
+
asked for, and nothing in the response says so; in an application where filters are also how
|
|
400
|
+
visibility is narrowed, a typo becomes a data exposure. It was also the odd one out: an unknown
|
|
401
|
+
field in `selection`, `conditions`, `orderBy` or a `joinGroup` aggregation has always been a
|
|
402
|
+
400.
|
|
403
|
+
|
|
404
|
+
## What changed
|
|
405
|
+
|
|
406
|
+
| Request | Before | After |
|
|
407
|
+
|---|---|---|
|
|
408
|
+
| `filters: { nosuchfield: 'x' }` | `200`, filter ignored | `400 Unknown filter field: nosuchfield` |
|
|
409
|
+
| `joinMultiple.<alias>.filters: { nosuchfield: 1 }` | `200`, filter ignored | `400 Unknown filter field: nosuchfield` |
|
|
410
|
+
| `joinLeft.<alias>.filters: { <extraFilter> }` | `200`, filter ignored | `400 Filter '<key>' is an extraFilter: …` |
|
|
411
|
+
| `filters: { knownField: undefined }` | ignored | ignored (unchanged) |
|
|
412
|
+
| `filters: { knownField: null }` | `IS NULL` | `IS NULL` (unchanged) |
|
|
413
|
+
|
|
414
|
+
Two related fixes ship with it:
|
|
415
|
+
|
|
416
|
+
- The check runs in the engine, not in the route schema, so `sqlApi.search()` behaves the same
|
|
417
|
+
as the HTTP routes. (A closed `additionalProperties: false` schema would not have worked:
|
|
418
|
+
Fastify runs Ajv with `removeAdditional: true`, so it would have *stripped* the key and
|
|
419
|
+
reproduced the same silent behaviour one layer up.)
|
|
420
|
+
- Join filters are validated **before any query runs**. They used to be validated inside the
|
|
421
|
+
side query, which is skipped entirely when the main result set is empty — so the same request
|
|
422
|
+
answered 400 or 200 depending on the data it matched.
|
|
423
|
+
|
|
424
|
+
## Who is affected
|
|
425
|
+
|
|
426
|
+
Callers that send filter keys the target table does not declare. Over-sending is the common
|
|
427
|
+
case: a frontend that spreads a whole form state into `filters`, or reuses one filter object
|
|
428
|
+
across two tables.
|
|
429
|
+
|
|
430
|
+
## Migration
|
|
431
|
+
|
|
432
|
+
Send only keys the table declares. `GET /agent/manifest` and the Swagger body schema both list
|
|
433
|
+
them per table (schema fields + `extraFilters` + computed fields).
|
|
434
|
+
|
|
435
|
+
For a form-state object, filter it before sending rather than relying on the server to ignore
|
|
436
|
+
the extras:
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
const ALLOWED = ['name', 'status', 'createdFrom'] as const;
|
|
440
|
+
const filters = Object.fromEntries(
|
|
441
|
+
Object.entries(formState).filter(([k, v]) => ALLOWED.includes(k) && v !== '')
|
|
442
|
+
);
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Passing `undefined` for an absent value is still safe and needs no filtering: only keys with a
|
|
446
|
+
defined value are validated.
|
|
447
|
+
|
|
448
|
+
## joinLeft and extraFilters
|
|
449
|
+
|
|
450
|
+
`joinLeft.<alias>.filters` used to advertise the parent table's `extraFilters` in Swagger and
|
|
451
|
+
accept them at runtime, applying nothing: `buildLeftJoinClauses` builds its condition inline and
|
|
452
|
+
never runs the target's `extendedCondition`, whose column references cannot be qualified with
|
|
453
|
+
the `LEFT JOIN` alias the parent gets there. They are no longer advertised, and sending one
|
|
454
|
+
returns a `400` naming the reason.
|
|
455
|
+
|
|
456
|
+
Use `joinMustExist` on the same relation when you need an extra filter on the parent — that path
|
|
457
|
+
delegates to the target's own `filters()` and does run `extendedCondition`.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,61 @@ 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
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Unknown `filters` keys are now rejected with `400 Unknown filter field: <key>`** instead
|
|
15
|
+
of being dropped in silence — on the main table and on every join family. The engine only
|
|
16
|
+
ever visited keys matching a schema field, an `extraFilters` entry or a computed field, so a
|
|
17
|
+
mistyped filter name came back as an unfiltered (wider) result set with no error: the
|
|
18
|
+
dangerous direction to fail in, and inconsistent with `selection`, `conditions`, `orderBy`
|
|
19
|
+
and aggregations, which have always answered 400. The check lives in the engine, so it
|
|
20
|
+
covers `sqlApi.search()` as well as the HTTP routes. `undefined` values still mean "filter
|
|
21
|
+
not supplied" and are ignored; an explicit `null` still filters by `IS NULL`. See
|
|
22
|
+
[BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--unknown-filter-keys-are-rejected).
|
|
23
|
+
- **`joinLeft.<alias>.filters` no longer advertises the parent's `extraFilters`.** They were
|
|
24
|
+
present in the generated body schema and in Swagger, but `buildLeftJoinClauses` never runs
|
|
25
|
+
the target's `extendedCondition` (its column references cannot be qualified with the
|
|
26
|
+
`LEFT JOIN` alias), so the filter applied nothing. They are now rejected with a `400` naming
|
|
27
|
+
the reason and pointing at `joinMustExist` on the same relation.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **Join validation no longer depends on the main result set.** `joinMultiple` / `joinLeft` /
|
|
32
|
+
`joinGroup` skip their side query entirely when the main query matched no rows, so everything
|
|
33
|
+
validated inside it was skipped with it — the same request answered 400 or 200 depending on
|
|
34
|
+
the data. This covered filter keys and `readExclude` on join filters, an explicit `selection`
|
|
35
|
+
naming an unknown field, and `joinGroup` aggregation fields including `aggregations.by`. Join
|
|
36
|
+
filters are now validated up front, and the selection and aggregation lists are resolved
|
|
37
|
+
before the empty-result bail-out.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **`fields` allowlist on `buildRelation`** — restricts which of the target table's fields are
|
|
42
|
+
reachable through one relation, without hiding them from the table's own routes the way
|
|
43
|
+
`readExclude` would. The relation is declared against a schema narrowed to the list, so
|
|
44
|
+
`selection`, `filters`, `conditions`, `orderBy`, aggregations and the generated
|
|
45
|
+
request/response schemas all reject anything outside it with `400 Unknown field`; the default
|
|
46
|
+
`'*'` selection is spelled out into the allowed columns instead of emitting a real `SELECT *`;
|
|
47
|
+
and computed fields on the target resolve against the narrowed schema, so one reading an
|
|
48
|
+
excluded column is a 400 rather than a way around the list. Declaration-time checks: the list
|
|
49
|
+
must include the join field, and is rejected on `allowedWriteJoins` (write paths resolve
|
|
50
|
+
`upsertMap` by schema identity and must write every column the caller sent). Fail-closed by
|
|
51
|
+
design — see [ADR 0011](./docs/adr/0011-join-fields-allowlist.md) for allowlist vs blocklist.
|
|
52
|
+
- **[ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md)** — a declared join is a read
|
|
53
|
+
grant: `onRequests` and `operations` are route-level and do not follow a join, so
|
|
54
|
+
`allowedReadJoins` grants read access to the target table under the *host* table's
|
|
55
|
+
authorization. Documents what does cross a join (`readExclude`, `tenantScope`, the relation's
|
|
56
|
+
schema), the trimmed-schema + explicit-`selection` pattern for column-level narrowing, and
|
|
57
|
+
the rejected alternatives (`canBeJoined` hook, `selection` as a ceiling).
|
|
58
|
+
|
|
59
|
+
- **`excludeTables` config option** — blacklist for `sqlapi-generate-schema`. Tables
|
|
60
|
+
listed in `excludeTables` in `sqlapi.config.ts` (exact names or `*` globs, e.g.
|
|
61
|
+
`knex_*`) are skipped during schema generation; their previously generated schema files
|
|
62
|
+
are removed as orphans on the next full run, like those of dropped tables (ADR 0009).
|
|
63
|
+
Does not apply to `sqlapi-generate-tables`, which is already explicit (ADR 0007).
|
|
64
|
+
|
|
10
65
|
## [0.1.12]
|
|
11
66
|
|
|
12
67
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,14 +1,63 @@
|
|
|
1
1
|
# fastify-auto-sqlapi
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Your database already knows its tables, columns and relations. This plugin turns that knowledge into a complete REST API inside your [Fastify](https://fastify.dev/) app.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Two CLI commands introspect PostgreSQL / MySQL / MariaDB and generate typed schemas; one `register()` call turns each table *you choose to expose* into seven endpoints: search with filters, advanced conditions, relation joins and aggregations; validated writes with nested child records; bulk upsert/delete; multi-tenant isolation; Swagger docs. Related tables don't need endpoints of their own — reads reach them through joins, writes through nested children — so a whole subgraph can live behind a single exposed table. What's left to write is what makes your product yours — auth, business rules, custom logic — as plain TypeScript hooks next to your other routes.
|
|
6
|
+
|
|
7
|
+
One request, to show the point — *"active customers with over 500 total in completed orders, with their order stats, biggest spenders first"*:
|
|
8
|
+
|
|
9
|
+
```jsonc
|
|
10
|
+
// POST /api/search/customer?orderBy=orders.sum.total DESC&page=1&itemsPerPage=20
|
|
11
|
+
{
|
|
12
|
+
"filters": { "status": "active" },
|
|
13
|
+
"joinGroup": {
|
|
14
|
+
"orders": {
|
|
15
|
+
"filters": { "status": "completed" },
|
|
16
|
+
"aggregations": { "sum": ["total"], "count": ["id"] }
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"conditions": [{ "field": "orders.sum.total", "method": "isGreater", "params": [500] }]
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Under the hood this is a fixed, request-shaped number of set-based queries — the main select plus one per requested join alias (EXISTS clauses and correlated subqueries ride inside them), **never one per returned row**. There are no lazy relations to accidentally loop over: the N+1 problem is structurally absent, not something you remember to avoid.
|
|
24
|
+
|
|
25
|
+
Writes read the same way — *"sync these clients and their tags: update the ones that exist, insert the missing ones, don't duplicate a tag"*:
|
|
26
|
+
|
|
27
|
+
```jsonc
|
|
28
|
+
// PUT /api/bulk/customer — all mains in one SQL; child FKs auto-filled from each parent
|
|
29
|
+
[
|
|
30
|
+
{ "main": { "email": "mario@acme.it", "name": "Mario" },
|
|
31
|
+
"secondaries": { "tags": [{ "tag": "vip" }, { "tag": "newsletter" }] } },
|
|
32
|
+
{ "main": { "email": "luigi@acme.it", "name": "Luigi" } }
|
|
33
|
+
]
|
|
34
|
+
// → 200 [{ "main": { "id": 1 }, "secondaries": { "tags": [{ "id": 10 }, { "id": 11 }] } },
|
|
35
|
+
// { "main": { "id": 2 } }]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Conflict keys are declared server-side (`upsertMap`: here customer on `email`, tag on `(customerId, tag)`), so re-sending the same payload updates rows instead of duplicating them — client-side sync without hand-written diffing.
|
|
39
|
+
|
|
40
|
+
And multi-tenancy is one plugin option (`getTenantId: (req) => req.user.organizationId`), enforced on reads *and* writes with zero code in handlers. Rows of other tenants are simply invisible (`404`); here a caller in org B tries to re-point one of their own orders to a customer belonging to org A:
|
|
41
|
+
|
|
42
|
+
```jsonc
|
|
43
|
+
// PUT /api/rest/order — order 7 is mine, customer 41 belongs to another tenant
|
|
44
|
+
{ "main": { "id": 7, "customerId": 41 } }
|
|
45
|
+
// → 403 Forbidden — cross-tenant references are rejected server-side
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
## Not an ORM, not GraphQL — a third thing
|
|
51
|
+
|
|
52
|
+
- **Not an ORM.** An ORM is a library *your* code uses to talk to the database — you still hand-write every endpoint on top of it. This plugin generates the endpoints themselves. And there is no ORM underneath either: no models, no migrations, no second schema to keep in sync. The database is the single source of truth (the CLI reads it from `information_schema`), and every request runs as plain parameterized SQL you can read with `debug: true`.
|
|
53
|
+
- **Not GraphQL.** GraphQL buys client-driven flexibility with a heavy toolchain: a schema layer, hand-written resolvers (and their N+1 traps), mutations written one by one, client libraries, and query-cost analysis to stop hostile requests. This plugin covers what most projects actually reach for GraphQL for — filter, paginate, join related data, aggregate, in one round trip — with a fixed JSON grammar over plain REST: curl-able, Swagger-documented, bounded by design (join depth is fixed, page and bulk sizes are capped). The write side — nested children, upserts, bulk — is generated too, which GraphQL never gives you for free. What you give up: arbitrarily deep nesting, per-field selection on the main table, subscriptions. If you need those, you need GraphQL; most CRUD backends don't.
|
|
54
|
+
- **Not a hosted black box.** PostgREST / Hasura / Supabase give you an instant API as a separate service, configured from the outside. This is a plugin inside your own app: hooks, validation and auth are TypeScript functions in your codebase, and you can always drop down to `app.sqlApi.*` or raw SQL in a custom route — same engine, no lock-in.
|
|
6
55
|
|
|
7
56
|
## Features
|
|
8
57
|
|
|
58
|
+
- **Zero boilerplate** — 7 endpoints per exposed table; related tables can stay behind joins and nested writes instead of getting endpoints of their own
|
|
59
|
+
- **No ORM** — raw SQL via `pg` + parameterized queries; set-based joins, so no N+1 by construction
|
|
9
60
|
- **Agent-ready** — `AGENTS.md` and the ADRs ship *inside* the npm package, so a coding agent can configure the library without reading the source or guessing intent
|
|
10
|
-
- **Zero boilerplate** — define your tables, get 7 endpoints each
|
|
11
|
-
- **No ORM** — raw SQL via `pg` + parameterized queries
|
|
12
61
|
- **TypeBox validation** — request/response schemas auto-generated from your DB
|
|
13
62
|
- **Joins** — four explicit families (`joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`) with alias support
|
|
14
63
|
- **Computed fields** — declare virtual fields as SQL expressions in `defineTable`; usable like schema fields in filters/orderBy/conditions, and opt-in projected in the response via `selectComputed`. Covers JSON extraction, derived columns, dialect-aware date bucketing
|
|
@@ -25,37 +74,6 @@ Point it at your database, and get a full REST API with search, advanced conditi
|
|
|
25
74
|
> with bound values, read the 0.1.6 entry — earlier versions could return wrong rows.
|
|
26
75
|
|
|
27
76
|
|
|
28
|
-
## Working with coding agents
|
|
29
|
-
|
|
30
|
-
The library is documented for two readers. Humans get this README; agents get task-oriented
|
|
31
|
-
files that ship in the published package, so they are already on disk after `npm install`:
|
|
32
|
-
|
|
33
|
-
| File | Contents |
|
|
34
|
-
| -------------------------------------- | ----------------------------------------------------------------- |
|
|
35
|
-
| `AGENTS.md` | Entry point: what the library does, which file to read for what |
|
|
36
|
-
| `AGENTS_BACKEND.md` | `defineTable` reference, hooks, tenancy, computed fields, CLI |
|
|
37
|
-
| `AGENTS_FRONTEND.md` | Request/response shapes for every endpoint |
|
|
38
|
-
| `docs/adr/` | Why the non-obvious behaviours are what they are |
|
|
39
|
-
|
|
40
|
-
Point your agent at them once:
|
|
41
|
-
|
|
42
|
-
> Read `node_modules/fastify-auto-sqlapi/AGENTS.md` before touching anything under `src/db/`.
|
|
43
|
-
|
|
44
|
-
Three design choices make the generated code predictable enough for an agent to write it
|
|
45
|
-
unsupervised:
|
|
46
|
-
|
|
47
|
-
- **Configuration is declarative and local.** A table is one `defineTable()` call — nothing to
|
|
48
|
-
wire across files, so a diff is reviewable at a glance.
|
|
49
|
-
- **One naming convention.** camelCase in requests, responses, hooks and validators; the
|
|
50
|
-
mapping to DB columns is automatic. There is no second convention to remember, and no
|
|
51
|
-
layer where the agent has to guess which one applies.
|
|
52
|
-
- **Errors are machine-readable.** A 400 carries `fields: [{ path, code, message }]`, so a
|
|
53
|
-
failing request tells the agent exactly what to fix instead of requiring a guess.
|
|
54
|
-
|
|
55
|
-
The ADRs matter more than they look: they answer the "why is it like this?" questions
|
|
56
|
-
(open-by-default, non-transactional bulk, always-updatable fields) that an agent would
|
|
57
|
-
otherwise resolve by inventing a workaround — or by proposing to change intentional behaviour.
|
|
58
|
-
|
|
59
77
|
## Quick Start
|
|
60
78
|
|
|
61
79
|
### 1. Install
|
|
@@ -76,6 +94,7 @@ export default {
|
|
|
76
94
|
schema: 'public', // PostgreSQL schema to introspect (default: 'public')
|
|
77
95
|
// dialect: 'mysql', // 'postgres' (default) | 'mysql' | 'mariadb'
|
|
78
96
|
// envFile: '.env.local', // env file to load (default: '.env')
|
|
97
|
+
// excludeTables: ['knex_*'], // tables to skip in schema generation ('*' = wildcard)
|
|
79
98
|
};
|
|
80
99
|
```
|
|
81
100
|
|
|
@@ -113,6 +132,12 @@ This introspects your tables and generates one TypeBox schema file per table in
|
|
|
113
132
|
`<outputDir>/schemas/` (e.g. `SchemaCustomer.ts`, `SchemaOrder.ts`). These files are
|
|
114
133
|
auto-generated — don't edit them; re-run the command after a DB change.
|
|
115
134
|
|
|
135
|
+
To keep some tables out of schema generation (migration bookkeeping, PostGIS internals, …)
|
|
136
|
+
list them in `excludeTables` in the config — exact table names or `*` globs (e.g.
|
|
137
|
+
`knex_*`). Excluded tables are invisible to the generator: their schemas are not created,
|
|
138
|
+
and any previously generated schema file is removed as an orphan on the next full run,
|
|
139
|
+
like that of a dropped table.
|
|
140
|
+
|
|
116
141
|
### 4. Generate the tables template
|
|
117
142
|
|
|
118
143
|
```bash
|
|
@@ -161,6 +186,37 @@ That's it. For a table called `customer`, you now have:
|
|
|
161
186
|
|
|
162
187
|
> **Note:** Search uses `POST` because filters are passed as JSON in the request body.
|
|
163
188
|
|
|
189
|
+
## Working with coding agents
|
|
190
|
+
|
|
191
|
+
The library is documented for two readers. Humans get this README; agents get task-oriented
|
|
192
|
+
files that ship in the published package, so they are already on disk after `npm install`:
|
|
193
|
+
|
|
194
|
+
| File | Contents |
|
|
195
|
+
| -------------------------------------- | ----------------------------------------------------------------- |
|
|
196
|
+
| `AGENTS.md` | Entry point: what the library does, which file to read for what |
|
|
197
|
+
| `AGENTS_BACKEND.md` | `defineTable` reference, hooks, tenancy, computed fields, CLI |
|
|
198
|
+
| `AGENTS_FRONTEND.md` | Request/response shapes for every endpoint |
|
|
199
|
+
| `docs/adr/` | Why the non-obvious behaviours are what they are |
|
|
200
|
+
|
|
201
|
+
Point your agent at them once:
|
|
202
|
+
|
|
203
|
+
> Read `node_modules/fastify-auto-sqlapi/AGENTS.md` before touching anything under `src/db/`.
|
|
204
|
+
|
|
205
|
+
Three design choices make the generated code predictable enough for an agent to write it
|
|
206
|
+
unsupervised:
|
|
207
|
+
|
|
208
|
+
- **Configuration is declarative and local.** A table is one `defineTable()` call — nothing to
|
|
209
|
+
wire across files, so a diff is reviewable at a glance.
|
|
210
|
+
- **One naming convention.** camelCase in requests, responses, hooks and validators; the
|
|
211
|
+
mapping to DB columns is automatic. There is no second convention to remember, and no
|
|
212
|
+
layer where the agent has to guess which one applies.
|
|
213
|
+
- **Errors are machine-readable.** A 400 carries `fields: [{ path, code, message }]`, so a
|
|
214
|
+
failing request tells the agent exactly what to fix instead of requiring a guess.
|
|
215
|
+
|
|
216
|
+
The ADRs matter more than they look: they answer the "why is it like this?" questions
|
|
217
|
+
(open-by-default, non-transactional bulk, always-updatable fields) that an agent would
|
|
218
|
+
otherwise resolve by inventing a workaround — or by proposing to change intentional behaviour.
|
|
219
|
+
|
|
164
220
|
## Table Configuration
|
|
165
221
|
|
|
166
222
|
Tables are configured with `defineTable()`. The only required fields are `primary` and the output of `exportTableInfo()`:
|
|
@@ -210,6 +266,8 @@ const TableCustomer = defineTable({
|
|
|
210
266
|
buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
|
|
211
267
|
buildRelation(SchemaSession, 'userId', SchemaUser, 'id', { unique: true }), // alias = 'user', N:1
|
|
212
268
|
buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true }),
|
|
269
|
+
// `fields` restricts what this relation exposes of the target — read joins only
|
|
270
|
+
buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true, fields: ['id', 'name'] }),
|
|
213
271
|
],
|
|
214
272
|
allowedWriteJoins: [
|
|
215
273
|
buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
|
|
@@ -367,6 +425,43 @@ are always available to your own code.
|
|
|
367
425
|
**3. Multi-tenancy** — `getTenantId` + `tenantScope` filter every query by tenant (see
|
|
368
426
|
[Multi-Tenant](#multi-tenant)).
|
|
369
427
|
|
|
428
|
+
### A declared join is a read grant
|
|
429
|
+
|
|
430
|
+
⚠️ **Neither of the first two layers follows a join.** `onRequests` and `operations` are
|
|
431
|
+
properties of the routes generated *for* a table; a join is resolved inside the query of the
|
|
432
|
+
**host** table's route, so the target's hooks never run and its `operations` whitelist does not
|
|
433
|
+
apply. A table with `operations: []` — no HTTP routes at all — is still fully readable through
|
|
434
|
+
any relation pointing at it.
|
|
435
|
+
|
|
436
|
+
So `allowedReadJoins` is a security decision: declaring a relation grants read access to the
|
|
437
|
+
target table under the **host** table's authorization.
|
|
438
|
+
|
|
439
|
+
What does follow a join: `readExclude`, `tenantScope`, and the schema the relation was declared
|
|
440
|
+
with. To expose a table broadly while keeping some columns narrow, give the relation a `fields`
|
|
441
|
+
allowlist:
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
allowedReadJoins: [
|
|
445
|
+
buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', {
|
|
446
|
+
unique: true,
|
|
447
|
+
fields: ['id', 'name'], // everything else on `user` is unreachable here
|
|
448
|
+
}),
|
|
449
|
+
],
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Every read surface validates against it — `selection`, `filters`, `conditions`, `orderBy`,
|
|
453
|
+
aggregations, and the generated request/response schemas — so `email` is `400 Unknown field`
|
|
454
|
+
everywhere, not just missing from the projection. It is fail-closed: a column added to `user`
|
|
455
|
+
later is not reachable through this relation until it is added to the list.
|
|
456
|
+
|
|
457
|
+
`fields` restricts **reading** only, and only through that relation. It is not a replacement for
|
|
458
|
+
`readExclude` (which hides a column from the table's own routes too) or for `onRequests` (which
|
|
459
|
+
decides who may call them).
|
|
460
|
+
|
|
461
|
+
Full rationale and the rejected alternatives in
|
|
462
|
+
[ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md) and
|
|
463
|
+
[ADR 0011](./docs/adr/0011-join-fields-allowlist.md).
|
|
464
|
+
|
|
370
465
|
### Request limits
|
|
371
466
|
|
|
372
467
|
Two hard caps bound how much work a single request can trigger (defense against accidental or
|
|
@@ -383,11 +478,14 @@ capped — they run your own trusted code.
|
|
|
383
478
|
|
|
384
479
|
### Write whitelist
|
|
385
480
|
|
|
386
|
-
Insert/update/bulk-upsert bodies
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
481
|
+
Insert/update/bulk-upsert bodies are `additionalProperties: false`: only columns present in the
|
|
482
|
+
generated Schema (as narrowed by `schemaOverrides` / `excludeFromCreation`) can be written. The
|
|
483
|
+
Schema is the write whitelist. Trim the generated Schema (or use `excludeFromCreation`) to keep
|
|
484
|
+
sensitive columns out of it.
|
|
485
|
+
|
|
486
|
+
An unexpected field is **stripped, not rejected**: Fastify runs Ajv with `removeAdditional: true`,
|
|
487
|
+
so the request succeeds and the column is simply never written. Mass assignment is prevented
|
|
488
|
+
either way, but do not expect a `400` to tell a client its extra field was ignored.
|
|
391
489
|
|
|
392
490
|
### Field-level update rules are product logic — use the hooks
|
|
393
491
|
|
|
@@ -487,12 +585,12 @@ Search with filters, advanced conditions, pagination, ordering, joins, and aggre
|
|
|
487
585
|
}
|
|
488
586
|
```
|
|
489
587
|
|
|
490
|
-
- **`filters`** — equality-based, flat key/value. Supports schema fields
|
|
588
|
+
- **`filters`** — equality-based, flat key/value. Supports schema fields, `extraFilters` and computed fields. A key that is none of those is a `400 Unknown filter field: <key>` — a mistyped filter must never come back as a wider result set. An explicit `null` filters by `IS NULL`; `undefined` means "not supplied" and is ignored.
|
|
491
589
|
- **`conditions`** — array of `{ field, method, params }`. Methods: `isEqual`, `isNotEqual`, `isGreater`, `isGreaterOrEqual`, `isLess`, `isLessOrEqual`, `isLike`, `isILike`, `isIn`, `isNotIn`, `isBetween`, `isNotBetween`, `isNull`, `isNotNull`.
|
|
492
590
|
- **`joinMustExist`** — EXISTS-based filter: "main rows where at least one related row matches". Accepts `{ filters, conditions }` (both optional). Aliases must come from `allowedReadJoins` declarations with `unique: false`.
|
|
493
591
|
- **`joinMultiple`** — fetches related child rows in a side query. Accepts `{ filters, conditions, selection }`. Same `unique: false` aliases.
|
|
494
592
|
- **`joinGroup`** — aggregations on the related table. Supports `sum`, `min`, `max`, `avg`, `count`, `distinctCount`, and optional `by` for GROUP BY (a schema field name or a computed-field name declared on the join table — e.g. for date bucketing declare a computed using `db.dateTrunc('month', qiCol('orderDate'))`). Accepts `{ filters, conditions }`. Same `unique: false` aliases.
|
|
495
|
-
- **`joinLeft`** — embeds an N:1 parent. Real `LEFT JOIN` is added on demand (only when the request has `filters`/`conditions` on the parent or uses 2-parti `orderBy` on this alias). Aliases must be declared with `unique: true`. Accepts `{ filters, conditions, selection }`.
|
|
593
|
+
- **`joinLeft`** — embeds an N:1 parent. Real `LEFT JOIN` is added on demand (only when the request has `filters`/`conditions` on the parent or uses 2-parti `orderBy` on this alias). Aliases must be declared with `unique: true`. Accepts `{ filters, conditions, selection }`. Its `filters` accept schema and computed fields only: the parent's `extraFilters` need an alias-aware `extendedCondition`, so they are rejected with a `400` here instead of being silently ignored — use `joinMustExist` on the same relation when you need one.
|
|
496
594
|
|
|
497
595
|
**Dot-notation in `orderBy` and `conditions`**:
|
|
498
596
|
|
|
@@ -4,7 +4,7 @@ import * as path from 'node:path';
|
|
|
4
4
|
import { loadConfig } from '../lib/cli/config.js';
|
|
5
5
|
import { buildConnectionString, introspectTables } from '../lib/cli/pg-introspect.js';
|
|
6
6
|
import { buildMysqlConnectionConfig, introspectMysqlTables } from '../lib/cli/mysql-introspect.js';
|
|
7
|
-
import { buildTableMap, generateSchemaFile } from '../lib/cli/schema-codegen.js';
|
|
7
|
+
import { applyExcludeTables, buildTableMap, generateSchemaFile } from '../lib/cli/schema-codegen.js';
|
|
8
8
|
import { loadEnvFile, CONSOLE_COLORS, display, displayAsTableRow, parseArgs, runCli } from './utils.js';
|
|
9
9
|
await runCli('fastify-auto-sqlapi: generating schemas', async () => {
|
|
10
10
|
const cliArgs = parseArgs({
|
|
@@ -36,6 +36,14 @@ await runCli('fastify-auto-sqlapi: generating schemas', async () => {
|
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
38
|
const tableMap = buildTableMap(rows);
|
|
39
|
+
// Config blacklist: excluded tables are invisible to the generator, so their
|
|
40
|
+
// schema files (if any) fall through to the orphan cleanup below.
|
|
41
|
+
const excluded = applyExcludeTables(tableMap, config.excludeTables);
|
|
42
|
+
if (excluded.length) {
|
|
43
|
+
display(`Excluded by config (excludeTables): ${excluded.join(', ')}`, CONSOLE_COLORS.gray);
|
|
44
|
+
}
|
|
45
|
+
// Note: no early return when everything is excluded — the orphan cleanup below
|
|
46
|
+
// must still run to remove the schema files of freshly excluded tables.
|
|
39
47
|
// Filter tables if --tables flag is provided
|
|
40
48
|
const tableNames = cliArgs.tables;
|
|
41
49
|
if (tableNames.length) {
|
|
@@ -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,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAOvE,MAAM,WAAW,aAAa;IAC5B,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;CACrB;AAED,MAAM,WAAW,YAAY;IAC3B,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;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,GAAG,aAAa,CAuDpE;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../../src/lib/agent/manifest.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAOvE,MAAM,WAAW,aAAa;IAC5B,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;CACrB;AAED,MAAM,WAAW,YAAY;IAC3B,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;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,GAAG,aAAa,CAuDpE;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,CA6CrE"}
|
|
@@ -114,6 +114,8 @@ export function renderAgentManifestMd(manifest) {
|
|
|
114
114
|
'serverGenerated fields: omit on insert (values from client are ignored).',
|
|
115
115
|
'readJoins aliases: `1:N` → joinMustExist/joinMultiple/joinGroup; `N:1` → joinLeft.',
|
|
116
116
|
'writeJoins aliases: keys for `secondaries`/`deletions` in writes.',
|
|
117
|
+
'filters: unknown keys are rejected (400), never ignored. extraFilters do not apply ' +
|
|
118
|
+
'under joinLeft — use joinMustExist on the same relation.',
|
|
117
119
|
'',
|
|
118
120
|
];
|
|
119
121
|
for (const t of Object.values(manifest.tables)) {
|
|
@@ -5,5 +5,12 @@ export declare function convertColType(udtName: string, columnInfo: {
|
|
|
5
5
|
is_auto_increment?: boolean;
|
|
6
6
|
}): string;
|
|
7
7
|
export declare function buildTableMap(rows: ColumnInfo[]): TableMap;
|
|
8
|
+
/**
|
|
9
|
+
* Remove from `tableMap` the tables matching any of the `excludeTables` config patterns.
|
|
10
|
+
* Patterns are matched against the DB table name (not the generated schema name);
|
|
11
|
+
* `*` is the only wildcard — everything else is literal.
|
|
12
|
+
* Returns the names of the excluded tables, in map order.
|
|
13
|
+
*/
|
|
14
|
+
export declare function applyExcludeTables(tableMap: TableMap, excludeTables?: string[]): string[];
|
|
8
15
|
export declare function generateSchemaFile(schemaName: string, tableName: string, fields: Record<string, string>, colMap: Record<string, string>, primary?: string[]): string;
|
|
9
16
|
//# sourceMappingURL=schema-codegen.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/schema-codegen.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE3D,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IAAE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAAE,GAC9F,MAAM,CA0DR;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CAqC1D;AAED,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,OAAO,CAAC,EAAE,MAAM,EAAE,GACjB,MAAM,CA8CR"}
|
|
1
|
+
{"version":3,"file":"schema-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/schema-codegen.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE3D,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IAAE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAAE,GAC9F,MAAM,CA0DR;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CAqC1D;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,EAAE,aAAa,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAoBzF;AAED,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,OAAO,CAAC,EAAE,MAAM,EAAE,GACjB,MAAM,CA8CR"}
|
|
@@ -85,6 +85,31 @@ export function buildTableMap(rows) {
|
|
|
85
85
|
return acc;
|
|
86
86
|
}, {});
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Remove from `tableMap` the tables matching any of the `excludeTables` config patterns.
|
|
90
|
+
* Patterns are matched against the DB table name (not the generated schema name);
|
|
91
|
+
* `*` is the only wildcard — everything else is literal.
|
|
92
|
+
* Returns the names of the excluded tables, in map order.
|
|
93
|
+
*/
|
|
94
|
+
export function applyExcludeTables(tableMap, excludeTables) {
|
|
95
|
+
if (excludeTables === undefined)
|
|
96
|
+
return [];
|
|
97
|
+
if (!Array.isArray(excludeTables) || excludeTables.some((p) => typeof p !== 'string')) {
|
|
98
|
+
throw new Error('Invalid config: "excludeTables" must be an array of strings.');
|
|
99
|
+
}
|
|
100
|
+
if (excludeTables.length === 0)
|
|
101
|
+
return [];
|
|
102
|
+
const regexes = excludeTables.map((pattern) => new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('\\*', '.*')}$`));
|
|
103
|
+
const excluded = [];
|
|
104
|
+
for (const schemaName of Object.keys(tableMap)) {
|
|
105
|
+
const tableName = tableMap[schemaName].name;
|
|
106
|
+
if (regexes.some((re) => re.test(tableName))) {
|
|
107
|
+
excluded.push(tableName);
|
|
108
|
+
delete tableMap[schemaName];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return excluded;
|
|
112
|
+
}
|
|
88
113
|
export function generateSchemaFile(schemaName, tableName, fields, colMap, primary) {
|
|
89
114
|
const fieldsStr = JSON.stringify(fields, null, 2).replace(/"(.*?)"\s*:\s*"Type(.*?)"/g, '$1: Type$2');
|
|
90
115
|
const colMapStr = JSON.stringify(colMap, null, 2);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAeb,MAAM,mBAAmB,CAAC;AAqoC3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CAqIvB"}
|