fastify-auto-sqlapi 0.2.1 → 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.
Files changed (108) hide show
  1. package/AGENTS.md +50 -0
  2. package/AGENTS_BACKEND.md +265 -7
  3. package/AGENTS_FRONTEND.md +3 -3
  4. package/BREAKING_CHANGES.md +156 -0
  5. package/CHANGELOG.md +265 -19
  6. package/README.md +260 -983
  7. package/dist/bin/generate-schema.js +49 -2
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/lib/agent/manifest.d.ts +4 -1
  12. package/dist/lib/agent/manifest.d.ts.map +1 -1
  13. package/dist/lib/agent/manifest.js +15 -3
  14. package/dist/lib/cli/mysql-introspect.d.ts +28 -0
  15. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  16. package/dist/lib/cli/mysql-introspect.js +49 -16
  17. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  18. package/dist/lib/cli/pg-introspect.js +53 -14
  19. package/dist/lib/cli/schema-codegen.d.ts +2 -1
  20. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  21. package/dist/lib/cli/schema-codegen.js +17 -4
  22. package/dist/lib/cli/tables-codegen.d.ts +4 -0
  23. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  24. package/dist/lib/cli/tables-codegen.js +125 -27
  25. package/dist/lib/engine/bulk/bulk-upsert.js +2 -2
  26. package/dist/lib/engine/read-hooks.d.ts +19 -0
  27. package/dist/lib/engine/read-hooks.d.ts.map +1 -0
  28. package/dist/lib/engine/read-hooks.js +20 -0
  29. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  30. package/dist/lib/engine/rest/get.js +5 -2
  31. package/dist/lib/engine/rest/insert.js +1 -1
  32. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  33. package/dist/lib/engine/rest/update.js +14 -10
  34. package/dist/lib/engine/search/join-families.d.ts +11 -2
  35. package/dist/lib/engine/search/join-families.d.ts.map +1 -1
  36. package/dist/lib/engine/search/join-families.js +11 -13
  37. package/dist/lib/engine/search/search.d.ts.map +1 -1
  38. package/dist/lib/engine/search/search.js +9 -3
  39. package/dist/lib/engine/validate.d.ts +3 -3
  40. package/dist/lib/engine/validate.d.ts.map +1 -1
  41. package/dist/lib/engine/write-helpers.d.ts +7 -3
  42. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  43. package/dist/lib/engine/write-helpers.js +27 -28
  44. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  45. package/dist/lib/schema/bulk-upsert.js +2 -1
  46. package/dist/lib/schema/get.d.ts +14 -0
  47. package/dist/lib/schema/get.d.ts.map +1 -0
  48. package/dist/lib/schema/get.js +18 -0
  49. package/dist/lib/schema/helpers.d.ts +21 -3
  50. package/dist/lib/schema/helpers.d.ts.map +1 -1
  51. package/dist/lib/schema/helpers.js +37 -5
  52. package/dist/lib/schema/insert.d.ts.map +1 -1
  53. package/dist/lib/schema/insert.js +7 -3
  54. package/dist/lib/schema/search.d.ts.map +1 -1
  55. package/dist/lib/schema/search.js +3 -11
  56. package/dist/lib/schema/update.d.ts.map +1 -1
  57. package/dist/lib/schema/update.js +7 -3
  58. package/dist/lib/sql-api.d.ts +9 -9
  59. package/dist/lib/sql-api.d.ts.map +1 -1
  60. package/dist/lib/sql-api.js +2 -1
  61. package/dist/lib/table-helpers.d.ts.map +1 -1
  62. package/dist/lib/table-helpers.js +123 -2
  63. package/dist/lib/tenant.d.ts +2 -2
  64. package/dist/lib/tenant.d.ts.map +1 -1
  65. package/dist/lib/tenant.js +7 -1
  66. package/dist/lib/write-access.d.ts +36 -0
  67. package/dist/lib/write-access.d.ts.map +1 -0
  68. package/dist/lib/write-access.js +58 -0
  69. package/dist/routes/auto/get.routes.d.ts.map +1 -1
  70. package/dist/routes/auto/get.routes.js +3 -2
  71. package/dist/routes/auto/plugin.js +2 -2
  72. package/dist/routes/auto/route-helpers.js +1 -1
  73. package/dist/routes/setup-swagger.d.ts.map +1 -0
  74. package/dist/{lib → routes}/sql-api-decorator.d.ts +1 -1
  75. package/dist/routes/sql-api-decorator.d.ts.map +1 -0
  76. package/dist/{lib → routes}/sql-api-decorator.js +4 -4
  77. package/dist/types/cli.d.ts +27 -1
  78. package/dist/types/cli.d.ts.map +1 -1
  79. package/dist/types/dml.d.ts +6 -4
  80. package/dist/types/dml.d.ts.map +1 -1
  81. package/dist/types/plugin.d.ts +24 -3
  82. package/dist/types/plugin.d.ts.map +1 -1
  83. package/dist/types/request.d.ts +24 -0
  84. package/dist/types/request.d.ts.map +1 -0
  85. package/dist/types/request.js +1 -0
  86. package/dist/types/schema.d.ts +15 -0
  87. package/dist/types/schema.d.ts.map +1 -1
  88. package/dist/types/search.d.ts +3 -0
  89. package/dist/types/search.d.ts.map +1 -1
  90. package/dist/types/table.d.ts +84 -11
  91. package/dist/types/table.d.ts.map +1 -1
  92. package/dist/types/validation.d.ts +3 -3
  93. package/dist/types/validation.d.ts.map +1 -1
  94. package/dist/types.d.ts +1 -0
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/types.js +1 -0
  97. package/docs/adr/0004-updates-always-open.md +2 -1
  98. package/docs/adr/0014-what-follows-a-write-join.md +84 -0
  99. package/docs/adr/0015-non-writable-columns.md +86 -0
  100. package/docs/adr/0016-a-view-is-a-table-config.md +92 -0
  101. package/docs/adr/0017-the-schema-is-the-contract.md +85 -0
  102. package/docs/adr/0018-one-seam-to-the-http-framework.md +68 -0
  103. package/docs/adr/README.md +5 -0
  104. package/package.json +1 -1
  105. package/dist/lib/setup-swagger.d.ts.map +0 -1
  106. package/dist/lib/sql-api-decorator.d.ts.map +0 -1
  107. /package/dist/{lib → routes}/setup-swagger.d.ts +0 -0
  108. /package/dist/{lib → routes}/setup-swagger.js +0 -0
@@ -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
@@ -536,3 +639,56 @@ getTenantId: (request) => request.user?.organizationId ?? null,
536
639
  Then decide, for each open table that relates to a scoped one, whether the relation was meant to
537
640
  be a read grant at all. If it was not, remove it from `allowedReadJoins` — a relation is the
538
641
  grant, and the scope is only the cap on it.
642
+
643
+ ---
644
+
645
+ # Breaking Change — secondaries run the child table's `beforeInsert`
646
+
647
+ *Landed in **0.2.2**.*
648
+
649
+ ## Why this changed
650
+
651
+ `processSecondaries` consulted the child table's configuration for its `excludeFromCreation` and
652
+ its `tenantScope`, but skipped its `beforeInsert`. A child table whose hook *transformed* a value
653
+ rather than adding one — encrypting a column, normalising a unit — therefore stored the raw
654
+ client value whenever its rows arrived as another table's secondaries, and correctly only through
655
+ its own route. Nothing failed at write time; the row read back as garbage later. See
656
+ [ADR 0014](./docs/adr/0014-what-follows-a-write-join.md).
657
+
658
+ ## What changed
659
+
660
+ - A child table's **`beforeInsert` now runs** for rows written as `secondaries`, after that
661
+ table's `excludeFromCreation` strip and before the engine's FK auto-fill (which stays
662
+ authoritative). The child's `validate` and `afterInsert` still do not run.
663
+ - The **get response schema honours `readExclude`**: it previously advertised fields the engine
664
+ never selects. `schemaOverrides` is unchanged — still write bodies only.
665
+ - The **update body now requires the primary key**. It identifies the row (`PUT /rest/:table`
666
+ carries it in `main`), but the generated PK field is `Optional` and that modifier reached the
667
+ update body unchanged, so a request omitting `main.id` built its `WHERE` on `undefined`
668
+ instead of being rejected with a 400.
669
+
670
+ ## Who is affected
671
+
672
+ - **A table of yours declares `beforeInsert` and is also a write-join target.** The hook now runs
673
+ on that path. If it was written assuming the table's own route — reading `req.params`, counting
674
+ invocations, calling an external service per record — it now runs where it did not before. It
675
+ receives the transaction connection, so what it writes rolls back with the host's write.
676
+ - **You send an update without `main.<pk>`.** It never identified a row — the `WHERE` was built
677
+ on `undefined` — and is now a 400.
678
+ - **A client of yours read a `readExclude`d field out of the get route's Swagger.** It was never
679
+ returned; only the documentation claimed it.
680
+
681
+ Not affected: tables with no `beforeInsert`, or none that are write-join targets. Nothing about
682
+ `schemaOverrides` changes.
683
+
684
+ ## Migration
685
+
686
+ For a `beforeInsert` that must keep running on the table's own route only, guard on the context
687
+ it needs rather than on the path:
688
+
689
+ ```typescript
690
+ beforeInsert: async (db, req, record) => {
691
+ record.slug = slugify(record.name); // shaping: correct on both paths, leave it
692
+ if (req.routeOptions?.url?.startsWith('/rest/')) await notify(record); // side effect: narrow it
693
+ },
694
+ ```
package/CHANGELOG.md CHANGED
@@ -7,7 +7,179 @@ 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]
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]
54
+
55
+ ### Added
56
+
57
+ - **`ApiRequest` — one seam between the core and the HTTP framework.** The engines, the schema
58
+ builders and the write pipeline never read a property off the request: it arrives from the
59
+ route, is forwarded to `getTenantId`, `validate` and the hooks, and is otherwise untouched. That
60
+ dependency was spread over thirteen files as a `FastifyRequest` type import; it is now declared
61
+ once in `src/types/request.ts` and exported from the package. `ApiRequest` *is* `FastifyRequest`,
62
+ so nothing changes for consumers — hooks keep their exact types. A new dependency-cruiser rule
63
+ fails the build on a `fastify` import (type-only included) anywhere under `src/lib/`,
64
+ `src/types/` or `src/bin/` but that file, and the two Fastify bridge helpers that lived in
65
+ `lib/` (`setupSwagger`, the `sqlApi` decorator) moved to `src/routes/` so the rule needs no
66
+ exceptions. See [ADR 0018](./docs/adr/0018-one-seam-to-the-http-framework.md).
67
+
68
+ - **Views are supported explicitly, and PostgreSQL materialized views at all.** A view was
69
+ already usable — both introspections read `information_schema.columns`, which lists view
70
+ columns like any other — but the generator treated it as a base table and **invented a
71
+ primary key from the column types**: on an aggregating view that takes a count as the key,
72
+ and `get/:id`, `update` and `delete` then address rows by it, silently. Introspection now
73
+ reports `is_view` on both engines, the generated Schema carries `isView`, and the table
74
+ generator refuses to guess: it uses `id` when the view has one and says the key was *inferred,
75
+ not read from the database*, and otherwise writes a `TODO_pick_a_unique_column` placeholder
76
+ with `operations: ['search']` and a real `defaultOrder` — a config that works as generated,
77
+ since search never reads the primary key. Writes start off in the template but are **not**
78
+ blocked at runtime: a view the engine can make updatable is a legitimate projection layer.
79
+ PostgreSQL **materialized views** are now introspected from `pg_catalog`; they are absent from
80
+ `information_schema` and were being skipped without a word. `sqlapi-generate-schema` prints
81
+ the views it found and the assumptions it had to make. See
82
+ [ADR 0016](./docs/adr/0016-a-view-is-a-table-config.md).
83
+
84
+ - **Columns the database computes are no longer offered as writable.** A
85
+ `GENERATED ALWAYS AS (<expr>)` column — PostgreSQL STORED, MySQL VIRTUAL or STORED — is
86
+ rejected by both engines when a write so much as names it, and the whole statement fails.
87
+ The plugin used to advertise those columns as ordinary writable fields, so a client sending
88
+ back what a read had just returned got a `500` for a payload nobody could have made valid.
89
+ `sqlapi-generate-schema` now detects them and records them in the generated Schema's
90
+ `generatedFields`; the runtime drops them from the insert, update and bulk-upsert bodies
91
+ (main and secondaries) and again inside the engines, which `sqlApi.*` reaches without those
92
+ schemas. **Regenerating the Schema files is enough** — the exclusion does not live in the
93
+ `Table*.ts` the generator never overwrites. The CLI also prints the columns it found, per
94
+ table, since a generated source file is not always read. See
95
+ [ADR 0015](./docs/adr/0015-non-writable-columns.md).
96
+
97
+ - **`writeExclude` on `defineTable`** — the write-side counterpart of `readExclude`, for the
98
+ non-writable columns introspection cannot know about (one a trigger owns, one a migration is
99
+ about to drop). Reads are untouched: the field stays projected, filterable and orderable. It
100
+ runs **after** the write hooks, unlike `excludeFromCreation` which is sanitized before them
101
+ (ADR 0005): a hook cannot put back a value the database will refuse. `defineTable` rejects an
102
+ unknown field, the primary key, and a field that is also `readExclude`d. It is static and
103
+ applies to everyone — a rule that depends on *who* is asking is still product logic (ADR 0004).
104
+
105
+ - **The agent manifest marks non-writable fields** `readOnly`, alongside the existing
106
+ `writeOnly` for `readExclude`.
107
+
108
+ - **`afterRead` — the read-side hook.** The counterpart of `beforeInsert`/`beforeUpdate`, for
109
+ turning a stored representation back into the API one: decrypting a column, unpacking a blob,
110
+ rescaling a unit. Called **once per result set** with camelCase rows mutated **in place** and
111
+ awaited, so a hook that calls a remote KMS gets one invocation per page instead of one per row.
112
+ Declared on the table that **owns** the column and it follows a join: the hook runs wherever
113
+ that table's rows surface — `get`, `search`, and a `joinMultiple`/`joinLeft` declared on
114
+ another table, with `ctx.source` and `ctx.alias` saying which — so a relation elsewhere needs
115
+ no extra wiring, the way `readExclude` and `tenantScope` already work (ADR 0010). It does not
116
+ run on `joinGroup` (aggregates, not table rows) nor on an empty result, and rows cannot be
117
+ added or removed: the pagination `COUNT` has already run. On the main table it runs *after* the
118
+ join side queries, which correlate on the values the database returned, so a hook rewriting the
119
+ column a relation joins on cannot break the correlation. `SearchParams`/`GetParams` now carry
120
+ the optional `request`, which `sqlApi.search()`/`sqlApi.get()` forward when given one.
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]
11
183
 
12
184
  ### Added
13
185
 
@@ -28,6 +200,7 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
28
200
  byte-identical SQL. The three forms do not mix — `defineTable` throws at startup on `anyOf`
29
201
  combined with `column` or `through`, on an empty or malformed `anyOf`, and on a scope
30
202
  declaring no owner column at all.
203
+
31
204
  - `QueryClient.bulkInsertOrUpdate` takes an optional trailing `immutableCols`: columns written
32
205
  on insert but left untouched when the statement lands on an existing row. Additive.
33
206
 
@@ -48,6 +221,7 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
48
221
  their own scope emit unchanged SQL. Migration — including the `getTenantId` body that now needs
49
222
  to tolerate an unauthenticated request — in
50
223
  [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--the-tenant-follows-the-request-not-the-table-it-addresses).
224
+
51
225
  - **Database errors no longer reach clients verbatim.** Any error escaping an auto-generated
52
226
  route that the plugin did not raise itself now answers `{"statusCode":500,"error":"Internal
53
227
  Server Error","message":"Internal Server Error"}` instead of the driver message, which names
@@ -68,6 +242,93 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
68
242
  controlled) rather than the response body (not, and these routes are open by default). See
69
243
  [ADR 0013](./docs/adr/0013-sanitized-db-errors.md).
70
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
+
71
332
  - **Unknown `filters` keys are now rejected with `400 Unknown filter field: <key>`** instead
72
333
  of being dropped in silence — on the main table and on every join family. The engine only
73
334
  ever visited keys matching a schema field, an `extraFilters` entry or a computed field, so a
@@ -77,6 +338,7 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
77
338
  covers `sqlApi.search()` as well as the HTTP routes. `undefined` values still mean "filter
78
339
  not supplied" and are ignored; an explicit `null` still filters by `IS NULL`. See
79
340
  [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--unknown-filter-keys-are-rejected).
341
+
80
342
  - **`joinLeft.<alias>.filters` no longer advertises the parent's `extraFilters`.** They were
81
343
  present in the generated body schema and in Swagger, but `buildLeftJoinClauses` never runs
82
344
  the target's `extendedCondition` (its column references cannot be qualified with the
@@ -93,25 +355,9 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
93
355
  filters are now validated up front, and the selection and aggregation lists are resolved
94
356
  before the empty-result bail-out.
95
357
 
96
- ### Added
358
+ ## [0.1.13]
97
359
 
98
- - **`fields` allowlist on `buildRelation`** — restricts which of the target table's fields are
99
- reachable through one relation, without hiding them from the table's own routes the way
100
- `readExclude` would. The relation is declared against a schema narrowed to the list, so
101
- `selection`, `filters`, `conditions`, `orderBy`, aggregations and the generated
102
- request/response schemas all reject anything outside it with `400 Unknown field`; the default
103
- `'*'` selection is spelled out into the allowed columns instead of emitting a real `SELECT *`;
104
- and computed fields on the target resolve against the narrowed schema, so one reading an
105
- excluded column is a 400 rather than a way around the list. Declaration-time checks: the list
106
- must include the join field, and is rejected on `allowedWriteJoins` (write paths resolve
107
- `upsertMap` by schema identity and must write every column the caller sent). Fail-closed by
108
- design — see [ADR 0011](./docs/adr/0011-join-fields-allowlist.md) for allowlist vs blocklist.
109
- - **[ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md)** — a declared join is a read
110
- grant: `onRequests` and `operations` are route-level and do not follow a join, so
111
- `allowedReadJoins` grants read access to the target table under the *host* table's
112
- authorization. Documents what does cross a join (`readExclude`, `tenantScope`, the relation's
113
- schema), the trimmed-schema + explicit-`selection` pattern for column-level narrowing, and
114
- the rejected alternatives (`canBeJoined` hook, `selection` as a ceiling).
360
+ ### Added
115
361
 
116
362
  - **`excludeTables` config option** — blacklist for `sqlapi-generate-schema`. Tables
117
363
  listed in `excludeTables` in `sqlapi.config.ts` (exact names or `*` globs, e.g.