fastify-auto-sqlapi 0.3.0 → 0.4.1

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 (38) hide show
  1. package/AGENTS.md +24 -111
  2. package/AGENTS_BACKEND.md +271 -1381
  3. package/AGENTS_FRONTEND.md +72 -47
  4. package/BREAKING_CHANGES.md +129 -0
  5. package/CHANGELOG.md +86 -1
  6. package/README.md +1 -1
  7. package/dist/bin/generate-schema.js +2 -2
  8. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  9. package/dist/lib/cli/mysql-introspect.js +24 -1
  10. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  11. package/dist/lib/cli/pg-introspect.js +21 -2
  12. package/dist/lib/cli/schema-codegen.d.ts +1 -1
  13. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  14. package/dist/lib/cli/schema-codegen.js +31 -3
  15. package/dist/lib/db.d.ts.map +1 -1
  16. package/dist/lib/db.js +187 -60
  17. package/dist/lib/dialect.d.ts +15 -10
  18. package/dist/lib/dialect.d.ts.map +1 -1
  19. package/dist/lib/dialect.js +12 -31
  20. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  21. package/dist/lib/engine/bulk/bulk-upsert.js +2 -1
  22. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  23. package/dist/lib/engine/rest/insert.js +2 -1
  24. package/dist/lib/engine/search/aggregations.d.ts +7 -0
  25. package/dist/lib/engine/search/aggregations.d.ts.map +1 -1
  26. package/dist/lib/engine/search/aggregations.js +8 -7
  27. package/dist/lib/engine/write-helpers.d.ts +13 -1
  28. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  29. package/dist/lib/engine/write-helpers.js +35 -0
  30. package/dist/lib/table-helpers.d.ts.map +1 -1
  31. package/dist/lib/table-helpers.js +41 -0
  32. package/dist/types/cli.d.ts +11 -0
  33. package/dist/types/cli.d.ts.map +1 -1
  34. package/dist/types/schema.d.ts +7 -0
  35. package/dist/types/schema.d.ts.map +1 -1
  36. package/docs/adr/0021-upsert-targets-a-verified-unique-key.md +153 -0
  37. package/docs/adr/README.md +1 -0
  38. package/package.json +1 -1
@@ -1,111 +1,136 @@
1
1
  # fastify-auto-sqlapi — client API (LLM reference)
2
2
 
3
- This file is the request GRAMMAR. For this deployment's tables/fields/aliases (the
4
- vocabulary), fetch `GET {p}/agent/manifest.md` if available, or consult Swagger.
3
+ This is the request GRAMMAR. The vocabulary (this deployment's tables, fields, join aliases, computed fields, extra filters) comes from `GET {p}/agent/manifest.md` when enabled, or from Swagger.
5
4
 
6
- Rules: all request/response fields are camelCase schema field names — how the database names its columns is invisible here and irrelevant, the server maps them. `{p}` = deployment prefix (e.g. `/api`). `{table}` = the table's key in the manifest, which may differ from the database table name (one table can be exposed under several keys, each with its own rules). Join keys are backend-declared ALIASES (default = joined table name; list per table in Swagger). Write responses are PK-only. Write bodies reject unknown properties (400) — send only real schema fields. Arrays for bulk are capped (default 1000/request; chunk client-side). Composite-PK tables have no get/delete/bulk-delete (404) — use search / update.
5
+ - All request/response fields are camelCase schema field names; the server maps them to columns.
6
+ - `{p}` = deployment prefix (e.g. `/api`). `{table}` = the table's key in the manifest, which may differ from the DB table name (one table can be exposed under several keys, each with its own rules).
7
+ - Join keys are backend-declared ALIASES (default: the joined table name).
8
+ - Write responses return PKs only. Send only real schema fields in write bodies: unknown ones are dropped or rejected (400), depending on the server.
9
+ - Composite-PK tables have no get/delete/bulk-delete routes (404): use search / update.
7
10
 
8
11
  ## Endpoints
9
12
 
10
13
  ```
11
- POST {p}/search/{table} body=search request → {table, main:[...], ...}
12
- GET {p}/rest/{table}/:id → {main:{...}} | 404
13
- POST {p}/rest/{table} body={main, secondaries?} → 201 {main:{id}, secondaries?}
14
- PUT {p}/rest/{table} body={main(+PK), secondaries?, deletions?} → {main:{id}, ...}
15
- DELETE {p}/rest/{table}/:id → {main:{id}} | 404
16
- PUT {p}/bulk/{table} body=[{main, secondaries?, deletions?},...] → [{main:{id}},...]
17
- POST {p}/bulk/{table}/delete body=[{id:1},{id:2}] → [{main:{id:1}},...]
14
+ POST {p}/search/{table} body=search request → {table, main:[...], ...}
15
+ GET {p}/rest/{table}/:id → {main:{...}} | 404
16
+ POST {p}/rest/{table} body={main, secondaries?} → 201 {main:{id}, secondaries?}
17
+ PUT {p}/rest/{table} body={main(+PK), secondaries?, deletions?} → {main:{id}, secondaries?, deletions?}
18
+ DELETE {p}/rest/{table}/:id → {main:{id}} | 404
19
+ PUT {p}/bulk/{table} body=[{main, secondaries?, deletions?}, ...] → [{main:{id}}, ...]
20
+ POST {p}/bulk/{table}/delete body=[{id:1}, {id:2}] → [{main:{id:1}}, ...] // key = the table's PK field
18
21
  ```
19
22
 
20
- ## SEARCH
23
+ ## Search
21
24
 
22
- Query string (all optional): `?orderBy=...&page=1&itemsPerPage=20&computeMin=f&computeMax=f&computeSum=f&computeAvg=f`
23
- Body (all optional; `{}` = first N rows). Everything below is combinable in one request:
25
+ Querystring, all optional: `orderBy`, `page`, `itemsPerPage`, `computeMin|Max|Sum|Avg=<field>`. Body, all optional and combinable; `{}` returns the first N rows.
24
26
 
25
27
  ```jsonc
26
28
  // POST {p}/search/customer?orderBy=orders.sum.total DESC, creator.name ASC, id&page=1&itemsPerPage=20&computeSum=total
27
29
  {
28
- "filters": { "status": "active", "deletedById": null }, // equality AND; null → IS NULL; extraFilters names (backend-defined, see manifest) also accepted here
29
- "conditions": [ // advanced, AND, same field repeatable
30
+ "filters": { "status": "active", "deletedById": null }, // equality, AND; null → IS NULL; also computed fields and backend extra filters (see manifest). Unknown key → 400
31
+ "conditions": [ // AND; the same field may repeat
30
32
  { "field": "total", "method": "isBetween", "params": [100, 500] },
31
33
  { "field": "name", "method": "isILike", "params": ["%mario%"] },
32
34
  { "field": "status", "method": "isIn", "params": [["active", "pending"]] },
33
- { "field": "orders.count.id", "method": "isGreaterOrEqual", "params": [4] } // HAVING-style: needs matching joinGroup below
35
+ { "field": "orders.count.id", "method": "isGreaterOrEqual", "params": [4] } // alias.fn.field, HAVING-style: needs the matching joinGroup below
34
36
  ],
35
- "selectComputed": ["displayName"], // backend computed fields, projected into main rows only if listed
36
- "joinMustExist": { // EXISTS on child: filters WHICH main rows return; no data fetched
37
+ "selectComputed": ["displayName"], // computed fields are projected into main rows only when listed
38
+ "joinMustExist": { // EXISTS on children: decides WHICH main rows return; fetches nothing
37
39
  "labels": { "filters": { "labelId": 1 }, "conditions": [] }
38
40
  },
39
- "joinMultiple": { // side query: fetches child rows OF the returned mains
40
- "orders": { "filters": { "status": "pending" }, "selection": "id,total,status" }
41
+ "joinMultiple": { // side query: child rows of the returned main rows
42
+ "orders": { "filters": { "status": "pending" }, "conditions": [], "selection": "id,total,status" }
41
43
  },
42
- "joinGroup": { // aggregations on child rows
44
+ "joinGroup": { // aggregates child rows of the returned main rows
43
45
  "orders": {
44
- "aggregations": {
45
- "by": "status", // optional GROUP BY (schema field or backend computed, e.g. month bucket)
46
- "sum": ["total"], "min": ["total"], "max": ["total"], "avg": ["total"],
47
- "count": ["id"], "distinctCount": ["status"]
48
- },
49
- "filters": { "status": "active" } // narrows child rows before aggregating
46
+ "aggregations": { "by": "status", "sum": ["total"], "min": ["total"], "max": ["total"], "avg": ["total"], "count": ["id"], "distinctCount": ["status"] }, // by: optional GROUP BY (field or computed)
47
+ "filters": { "status": "active" }, "conditions": [] // narrow children before aggregating
50
48
  }
51
49
  },
52
- "joinLeft": { // N:1 parent embed (alias declared unique by backend)
53
- "creator": { "selection": "id,name" } // filters/conditions here EXCLUDE mains whose parent doesn't match; schema fields only (extraFilters NOT usable here)
50
+ "joinLeft": { // N:1 parent (alias declared unique by the backend)
51
+ "creator": { "selection": "id,name", "filters": {}, "conditions": [] } // filters here EXCLUDE mains whose parent does not match; no extra filters here
54
52
  }
55
53
  }
56
54
  ```
57
55
 
58
- Response (each section present only if requested):
56
+ Response; each section is present only if requested:
59
57
 
60
58
  ```jsonc
61
59
  {
62
60
  "table": "customer",
63
61
  "main": [{ "id": 1, "name": "Mario", "creatorId": 7, "displayName": "..." }],
64
- "joinMultiple": { "orders": [{ "id": 10, "customerId": 1, "total": 50 }] },
62
+ "joinMultiple": { "orders": [{ "id": 10, "customerId": 1, "total": 50 }] }, // flat list for all mains: group client-side by FK
65
63
  "joinGroup": { "orders": {
66
- "sum": { "total": 150 }, // keyed by fn when no "by"
67
- "rows": [{ "by": "pending", "sum_total": 100 }] // rows[] when "by" is set
64
+ "sum": { "total": 150 }, // no "by": ONE total over the children of ALL returned mains
65
+ "rows": [{ "by": "pending", "sum_total": 100 }] // with "by": one row per group; by = FK field gives per-main values
68
66
  }},
69
- "joinLeft": { "creator": [{ "id": 7, "name": "Alice" }] }, // deduped; map client-side: main.creatorId === creator.id
70
- "pagination": { // present when page/itemsPerPage sent
67
+ "joinLeft": { "creator": [{ "id": 7, "name": "Alice" }] }, // deduplicated: match main.creatorId === creator.id
68
+ "pagination": { // only when page or itemsPerPage is sent
71
69
  "total": 25, "pages": 3,
72
- "computed": { "sum": { "total": 999 } }, // computeMin/Max/Sum/Avg (require pagination)
70
+ "computed": { "sum": { "total": 999 } }, // computeMin/Max/Sum/Avg over all matching rows; require pagination
73
71
  "paginator": { "page": 1, "itemsPerPage": 20 }
74
72
  }
75
73
  }
76
74
  ```
77
75
 
78
- `orderBy` forms (comma-separable): `field [ASC|DESC]` | `alias.parentField` (joinLeft-eligible alias, adds LEFT JOIN) | `alias.fn.field` (fn: sum|min|max|avg|count|distinctCount — the joinGroup with that fn+field MUST be in the body; rows without children sort as 0). For 3-part orderBy and HAVING-style conditions: `aggregations.by` is allowed only when it equals the correlation FK (else 400); aggregation orderBy is rejected (400) on tables with distinctResults.
76
+ Pagination:
77
+ - Without `page`/`itemsPerPage`, the server caps rows at its maximum (default 1000) and returns no `pagination` block.
78
+ - With `page` only, `itemsPerPage` defaults to 500 (or the server maximum if lower).
79
+ - Both are integers ≥ 1. `itemsPerPage` above the server maximum → 400.
79
80
 
80
- Condition methods (params arity): `isEqual|isNotEqual|isGreater|isGreaterOrEqual|isLess|isLessOrEqual|isNotGreater|isNotGreaterOrEqual|isNotLess|isNotLessOrEqual` → `[v]`; `isLike|isNotLike|isILike|isNotILike` → `["%p%"]`; `isBetween|isNotBetween` → `[from,to]`; `isIn|isNotIn` → `[[v1,v2]]`; `isNull|isNotNull` → `[]`. Unknown field or method → 400; so is a `params` that is not an array or is shorter than the arity above (`isNull`/`isNotNull` take none).
81
+ `orderBy` is a comma-separated list, each part one of:
82
+ - `field [ASC|DESC]`, including computed fields.
83
+ - `alias.parentField`: joinLeft alias; adds the LEFT JOIN.
84
+ - `alias.fn.field`, with fn one of `sum|min|max|avg|count|distinctCount`. The joinGroup with that fn and field must be in the body.
81
85
 
82
- ## WRITES
86
+ For `alias.fn.field`, in both `orderBy` and conditions, the joinGroup `by` must be absent or equal to the FK (else 400). In `orderBy` it is also a 400 on tables with distinct results.
87
+
88
+ A main without children (or none left after the joinGroup filters) has `count`, `distinctCount` and `sum` = 0, but `min`, `max` and `avg` = null. Null sorts where the database puts it (PostgreSQL: last in ASC, first in DESC; MySQL/MariaDB: first in ASC, last in DESC) and fails every comparison: `max < 5` excludes such mains; `isNull` selects them.
89
+
90
+ Condition methods and `params` arity:
91
+ - `[v]`: `isEqual|isNotEqual|isGreater|isGreaterOrEqual|isLess|isLessOrEqual|isNotGreater|isNotGreaterOrEqual|isNotLess|isNotLessOrEqual`
92
+ - `["%p%"]`: `isLike|isNotLike|isILike|isNotILike`
93
+ - `[from, to]`: `isBetween|isNotBetween`
94
+ - `[[v1, v2]]`: `isIn|isNotIn`
95
+ - `[]`: `isNull|isNotNull`
96
+
97
+ An unknown field or method, or `params` that is not an array or is too short → 400.
98
+
99
+ ## Writes
83
100
 
84
101
  ```jsonc
85
- // POST {p}/rest/customer (insert; omit auto-generated PKs)
102
+ // POST {p}/rest/customer — insert; omit auto-generated PKs
86
103
  { "main": { "name": "Mario", "email": "m@t.it" },
87
- "secondaries": { "orders": [{ "total": 50, "status": "pending" }] } } // child FK auto-filled from new main PK
104
+ "secondaries": { "orders": [{ "total": 50, "status": "pending" }] } } // child FK auto-filled from the new main PK
88
105
  // → 201 { "main": { "id": 1 }, "secondaries": { "orders": [{ "id": 10 }] } }
89
106
 
90
- // PUT {p}/rest/customer (update; main MUST include PK; partial fields ok; null = set column NULL)
107
+ // PUT {p}/rest/customer — update; main MUST include the PK; send only the fields to change; null sets NULL
91
108
  { "main": { "id": 1, "email": "new@t.it" },
92
- "secondaries": { "orders": [{ "total": 75 }] }, // inserts/upserts children, FK auto-filled
93
- "deletions": { "orders": [{ "id": 10 }] } } // deletes children by PK (scoped to this main)
109
+ "secondaries": { "orders": [{ "total": 75 }] }, // insert (or upsert) children, FK auto-filled
110
+ "deletions": { "orders": [{ "id": 10 }] } } // delete children by PK, only children of this main
94
111
  // → 200 { "main": { "id": 1 }, "secondaries": { "orders": [{ "id": 20 }] }, "deletions": { "orders": [{ "id": 10 }] } }
95
112
 
96
- // PUT {p}/bulk/customer (bulk upsert; one SQL for mains; per-item secondaries/deletions)
113
+ // PUT {p}/bulk/customer — bulk upsert; items need not share keys: an omitted field keeps the stored value (update) or takes the column default (insert)
97
114
  [ { "main": { "name": "Mario" }, "secondaries": { "orders": [{ "total": 50 }] } },
98
115
  { "main": { "name": "Luigi" } } ]
99
116
  // → 200 [ { "main": { "id": 1 }, "secondaries": {...} }, { "main": { "id": 2 } } ]
100
117
  ```
101
118
 
119
+ Upsert tables (see manifest):
120
+ - Every row, main or child, carries its conflict key: missing, null, or shared by two rows of one request → 400.
121
+ - When the key is the id, rows without an id are inserted.
122
+ - Bulk arrays are capped (default 1000): chunk client-side.
123
+
102
124
  ## Errors
103
125
 
104
- 400 (schema or business validation) — map `fields[].path` to form fields:
126
+ 400 (schema or business validation): map `fields[].path` to form fields.
105
127
 
106
128
  ```json
107
129
  { "statusCode": 400, "error": "Bad Request", "message": "Validation failed",
108
130
  "fields": [ { "path": "body.main.name", "code": "required", "message": "must have required property 'name'" } ] }
109
131
  ```
110
132
 
111
- Other: 404 (get/delete not found), 400 `itemsPerPage exceeds the maximum` (lower it), 500 raw DB errors (constraint violations surface unmapped). Fixed complexity caps, per search: **100 `conditions`** per section (top level and each join family) and **20** comma-separated `orderBy` parts (`orderBy` string ≤ 1024 chars) — over either → 400: collapse a long list of equality conditions into one `isIn`. `paginator.page` and `paginator.itemsPerPage` must be integers ≥ 1 — send numbers, not querystring strings.
133
+ - 403: tenant violation.
134
+ - 404: record not found, or outside your tenant.
135
+ - 500: DB errors (e.g. constraint violations), unmapped, with a `requestId` to report.
136
+ - Per search, max 100 `conditions` per section (top level and each join) and max 20 `orderBy` parts (≤ 1024 chars); over either → 400. Collapse many equality conditions into one `isIn`.
@@ -744,3 +744,132 @@ export const dbTables: DbTables = { customer_order: TableCustomerOrder };
744
744
 
745
745
  A client that read `table` from a search response to recover the physical table name should
746
746
  use the key it called instead — that is now what `table` returns.
747
+
748
+ ---
749
+
750
+ # Breaking Change — an upsert targets a unique key verified at startup
751
+
752
+ *Landed in **0.4.0**.* Decision and rationale: [ADR 0021](./docs/adr/0021-upsert-targets-a-verified-unique-key.md).
753
+
754
+ ## Why this changed
755
+
756
+ `upsertMap` accepted any column list, and the dialects disagreed on what it meant:
757
+
758
+ - **No unique index on the declared columns**: PostgreSQL failed every write; MySQL never
759
+ conflicted and inserted duplicates, silently.
760
+ - **MySQL `ON DUPLICATE KEY UPDATE` fires on any unique index**, not on the declared key: a row
761
+ carrying another row's unique value (an `email`, an `id`) updated *that* row, silently, where
762
+ PostgreSQL refused the write.
763
+ - **A natural-key upsert on MySQL returned wrong PKs** for the rows it updated, and secondaries
764
+ were attached to them.
765
+
766
+ ## What changed
767
+
768
+ - **The generated Schema carries `uniqueKeys`**, the table's unique keys other than the
769
+ primary key — emitted even when empty. Partial, expression and (MySQL) prefix indexes are not
770
+ keys an upsert can target and are left out.
771
+ - **`defineTable` refuses an `upsertMap` rule that is not exactly the Schema's `primaryKey` or
772
+ one of its `uniqueKeys`** (same fields, any order). This includes rules on write-join child
773
+ schemas — the child still needs no `defineTable` of its own. A rule on a view is refused.
774
+ - **Every upserted row must carry its key — `400` otherwise**: a natural key must be present
775
+ and not null; the primary key may be absent altogether (a plain insert with a generated key)
776
+ but not partial or null. Two rows of one request with the same key are a `400` too.
777
+ - **MySQL and MariaDB upsert by key** — probe, insert, update — instead of
778
+ `ON DUPLICATE KEY UPDATE`. A row whose other unique column belongs to another row is now a
779
+ database error (sanitized 500) on every dialect, never a write to that other row.
780
+
781
+ ## Who is affected
782
+
783
+ - **Schemas generated before this release** used with a natural-key rule (`['email']`,
784
+ `['customerId', 'tag']` that is not the PK…): `defineTable` throws until they are
785
+ regenerated. Rules on the primary key keep working — `primaryKey` was already generated.
786
+ - **A rule on columns without a unique index**: it never worked on PostgreSQL, and on MySQL it
787
+ was inserting duplicates. `defineTable` now throws.
788
+ - **Clients sending upsert rows without their key**, or twice the same key in one request:
789
+ `400`.
790
+ - **MySQL/MariaDB**: an upsert costs two to three statements per chunk instead of one.
791
+ - **A driver configured without `CLIENT_FOUND_ROWS`** (MySQL/MariaDB): the upsert checks the
792
+ rows its UPDATE matched, and without that flag the driver reports changed rows instead —
793
+ a row re-sent unchanged would be reported as missing. `mysql2` sets the flag by default.
794
+ - **Code using the exported `SqlDialect` type or `getDialect()` for upserts**: `upsertSql()`
795
+ and `upsertUpdateSet()` are gone. They are replaced by `onConflict?: { sql(), updateSet() }`,
796
+ present on PostgreSQL only — its absence is what tells MySQL/MariaDB to upsert by key.
797
+ A custom `SqlDialect` implementation no longer compiles until it drops the two methods.
798
+
799
+ ## Migration
800
+
801
+ 1. Regenerate the schemas: `npx sqlapi-generate-schema`. Each Schema now ends with
802
+ `uniqueKeys: [...]`.
803
+ 2. Start the application: a rule that is not a unique key fails with a message listing the
804
+ keys the table has, e.g.
805
+ `upsertMap rule on 'customer' (email) is not a unique key of the table. An upsert must target exactly one: (id) primary key (ADR 0021).`
806
+ Either add the unique index the rule assumed, or point the rule at an existing key.
807
+ 3. Make sure every upsert row carries its key. For a write-join child keyed on
808
+ `(parentId, lang)`, the FK is filled from the parent — the client sends `lang`.
809
+ 4. If you use `SqlDialect` directly: build an upsert through `QueryClient.insertOrUpdate` /
810
+ `bulkInsertOrUpdate`, which pick the right strategy per dialect, rather than from the
811
+ dialect's clause. The PostgreSQL clause, if you need it, is now
812
+ `getDialect('postgres').onConflict!.sql(keys, getDialect('postgres').onConflict!.updateSet(fields, keepAsIs))`
813
+ — `updateSet` returns `''` when nothing is left to update, as before. A custom dialect
814
+ implements `onConflict` only when its upsert syntax names its conflict target; otherwise it
815
+ leaves it out and gets the upsert by key.
816
+
817
+ ```typescript
818
+ // before
819
+ const dialect = getDialect('postgres');
820
+ dialect.upsertSql(quotedKeys, dialect.upsertUpdateSet(fields, conflictKeys));
821
+
822
+ // after
823
+ const { onConflict } = getDialect('postgres');
824
+ onConflict!.sql(quotedKeys, onConflict!.updateSet(fields, conflictKeys));
825
+ ```
826
+
827
+ ---
828
+
829
+ # Breaking Change — `min`, `max` and `avg` of a main row without children are NULL
830
+
831
+ *Landed in **0.4.1**.*
832
+
833
+ ## Why this changed
834
+
835
+ `orderBy` and HAVING-style conditions on `<alias>.<fn>.<field>` run a correlated subquery per
836
+ main row, and every aggregate was wrapped in `COALESCE(…, 0)`. Over no child rows, that 0 is
837
+ the right answer for `count`, `distinctCount` and `sum`, but `min`, `max` and `avg` have no
838
+ value at all:
839
+
840
+ - **PostgreSQL**: on a text, char, date or timestamp column the request failed with a 500,
841
+ `COALESCE types … and integer cannot be matched`.
842
+ - **MySQL/MariaDB**: no error, but those mains were compared as `0` — `max(orderDate) <
843
+ '2026-03-01'` matched every main without children.
844
+ - **Both, numeric columns**: a 0 that no child row holds decided the ordering and conditions.
845
+
846
+ ## What changed
847
+
848
+ - `count`, `distinctCount` and `sum` over no rows are still 0.
849
+ - `min`, `max` and `avg` over no rows are NULL. In `orderBy` they sort where the database puts
850
+ NULL (PostgreSQL: last in ASC, first in DESC; MySQL/MariaDB: first in ASC, last in DESC). In
851
+ conditions they fail every comparison; `isNull` / `isNotNull` select them.
852
+ - "Without children" includes children all excluded by the joinGroup's own `filters` /
853
+ `conditions`.
854
+ - `result.joinGroup` is unchanged: it was never coalesced.
855
+
856
+ ## Who is affected
857
+
858
+ Clients that order or filter by `min`, `max` or `avg` of a **numeric** column on tables where
859
+ some main rows have no children:
860
+
861
+ - `orderBy=orders.min.total ASC` on PostgreSQL: those mains move from first to last.
862
+ `… DESC`: from last to first. On MySQL/MariaDB the order is unchanged (NULL sorts as the
863
+ smallest value, where the 0 was for non-negative columns).
864
+ - `conditions: [{ field: 'orders.max.total', method: 'isLess', params: [5] }]`: those mains
865
+ no longer match. `isGreater` with a negative value, `isEqual` with 0 and similar change the
866
+ same way.
867
+
868
+ ## Migration
869
+
870
+ - To drop mains without children from a result, add `{ field: 'orders.count.id', method:
871
+ 'isGreater', params: [0] }` (or `isNotNull` on the aggregate).
872
+ - To list only them, use `isNull` on the aggregate, or `isEqual 0` on `count`.
873
+ - Conditions are ANDed, so "below 5 **or** no children" is no longer one request: run the
874
+ `isNull` search separately, or compare against a `count` instead where that expresses the
875
+ same thing.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,89 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.4.1]
13
+
14
+ ### Changed
15
+
16
+ - **BREAKING: `min`, `max` and `avg` of a main row without children are NULL, not 0, in
17
+ `orderBy` and HAVING-style conditions.** The correlated subquery behind `alias.fn.field`
18
+ wrapped every aggregate in `COALESCE(…, 0)`. That 0 is right for `count`, `distinctCount` and
19
+ `sum`, which keep it; for `min`/`max`/`avg` it invented a value the children do not have. On
20
+ numeric columns, such mains now sort where the database puts NULL (PostgreSQL: last in ASC,
21
+ first in DESC; MySQL/MariaDB: first in ASC, last in DESC) instead of as 0, and a condition
22
+ like `max < 5` no longer matches them (`isNull` does). See
23
+ [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--min-max-and-avg-of-a-main-row-without-children-are-null).
24
+
25
+ ### Fixed
26
+
27
+ - **PostgreSQL: `orderBy` or a condition on `min`/`max`/`avg` of a non-numeric column no longer
28
+ fails.** `orderBy=charge.max.monthYear` over a `character(7)` column (or any text, date or
29
+ timestamp one) was a 500, `COALESCE types character and integer cannot be matched`, whenever
30
+ the request reached the database. MySQL/MariaDB did not fail but compared those mains as `0`:
31
+ `max(orderDate) < '2026-03-01'` matched every main without children. Both now compare the
32
+ aggregate as NULL, as above.
33
+
34
+ ## [0.4.0]
35
+
36
+ ### Changed
37
+
38
+ - **BREAKING: an upsert targets a unique key verified at startup** ([ADR 0021](./docs/adr/0021-upsert-targets-a-verified-unique-key.md)).
39
+ `upsertMap` accepted any column list, and on MySQL `ON DUPLICATE KEY UPDATE` fired on *any*
40
+ unique index: a declared key without a unique index inserted duplicates, and a row carrying
41
+ another row's unique value updated that other row — both silently, where PostgreSQL failed.
42
+ Now:
43
+ - the generated Schema carries `uniqueKeys` (partial, expression and prefix indexes left out);
44
+ - `defineTable` refuses a rule that is not exactly the `primaryKey` or a `uniqueKeys` entry —
45
+ write-join children included, still without a `defineTable` of their own;
46
+ - an upserted row without its key, or two rows of one request with the same key, is a `400`
47
+ (a row naming no PK field at all is still a plain insert when the key is the PK);
48
+ - MySQL and MariaDB upsert by key — probe, insert, update: two to three statements per chunk
49
+ instead of one. PostgreSQL keeps `ON CONFLICT (key)`, unchanged;
50
+ - the exported `SqlDialect` type loses `upsertSql()` and `upsertUpdateSet()`, replaced by
51
+ `onConflict?: { sql(), updateSet() }`, present on PostgreSQL only.
52
+ Regenerate the schemas; see
53
+ [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--an-upsert-targets-a-unique-key-verified-at-startup).
54
+
55
+ ### Fixed
56
+
57
+ - **Bulk writes no longer lose or overwrite fields when items do not share the same keys.**
58
+ `bulkUpsert` (route and `sqlApi`), secondaries of every write, and
59
+ `QueryClient.bulkInsert` / `bulkInsertOrUpdate` took the column list of the whole batch from
60
+ the first record. A field present only on a later item was silently dropped; a field present
61
+ only on an earlier item was bound as NULL for the others — skipping the column default on
62
+ insert (or failing on a `NOT NULL DEFAULT` column) and wiping the stored value on upsert.
63
+ Each record now writes exactly its own fields: records are grouped by column set, one
64
+ statement per group, PKs still returned in input order. Batches whose items share the same
65
+ keys still run as a single statement.
66
+ - **An upsert item holding only its conflict keys returns its PK.** Such an item has nothing
67
+ to update, so an existing row is left untouched — but postgres `DO NOTHING` returns no row
68
+ for it and mysql's `insertId` says nothing about it: the single upsert returned `undefined`
69
+ and a bulk upsert failed with a 500 after writing. The PK now comes from the item when it
70
+ holds it, otherwise it is looked up by the conflict keys (one `SELECT` per chunk).
71
+ - **MySQL: an upsert by a natural key returns the PK of the row it updated.** With a conflict
72
+ key that is not the PK (the README's `email` sync), the PKs were synthesized as
73
+ `insertId + n`, which counts only inserted rows: re-sending a payload returned ids of other
74
+ rows, or of rows that do not exist, and secondaries were attached to them. The upsert by key
75
+ above takes them from its probe.
76
+
77
+ ### Documentation
78
+
79
+ - **Agent files rewritten for LLM readers** — `AGENTS.md`, `AGENTS_BACKEND.md` and
80
+ `AGENTS_FRONTEND.md` went from 107 KB to 39 KB. Duplicated sections were merged, the
81
+ `defineTable` reference is now one annotated example, and markdown links and emoji were
82
+ removed. No API changed; several statements were wrong and are corrected:
83
+ - write bodies are closed, but under Fastify's default `removeAdditional: true` an unknown
84
+ property is stripped silently; it is a `400` only with `removeAdditional: false`. Unknown
85
+ `filters` keys, by contrast, are always a `400`;
86
+ - `ConditionBuilder.raw()` takes `?` markers, not `$1`;
87
+ - a computed field's `values` is required (the translated-jsonb example omitted it);
88
+ - a `joinGroup` without `by` returns one total over the children of all returned main rows,
89
+ not one per row; `by` set to the FK gives per-row values;
90
+ - search pagination is querystring-only (`page`, `itemsPerPage`), and `distinctResults`
91
+ rejects only aggregation `orderBy`, not aggregation conditions;
92
+ - the plugin options now list `agentManifest`, `sqlApi.search` lists `selectComputed` and
93
+ `maxRows`, and the exports list is complete.
94
+
12
95
  ## [0.3.0]
13
96
 
14
97
  ### Changed
@@ -685,7 +768,9 @@ the join API redesign (documented in [BREAKING_CHANGES.md](./BREAKING_CHANGES.md
685
768
  primary key support in updates, transactional insert/update with secondaries, the hook matrix,
686
769
  and bulk operations.
687
770
 
688
- [Unreleased]: https://github.com/LucaRainone/fastify-auto-sqlapi/compare/v0.3.0...HEAD
771
+ [Unreleased]: https://github.com/LucaRainone/fastify-auto-sqlapi/compare/v0.4.1...HEAD
772
+ [0.4.1]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.4.1
773
+ [0.4.0]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.4.0
689
774
  [0.3.0]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.3.0
690
775
  [0.2.4]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.4
691
776
  [0.2.3]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.3
package/README.md CHANGED
@@ -52,7 +52,7 @@ Writes read the same way — *"sync these clients and their tags: update the one
52
52
  // { "main": { "id": 2 } }]
53
53
  ```
54
54
 
55
- 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.
55
+ Conflict keys are declared server-side (`upsertMap`: here customer on `email`, tag on `(customerId, tag)`) and checked at startup against the unique keys the database enforces, so re-sending the same payload updates rows instead of duplicating them and never touches a row through any other key — client-side sync without hand-written diffing.
56
56
 
57
57
  And multi-tenancy is one plugin option (`getTenantId: (req) => req.user?.organizationId ?? null`), 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:
58
58
 
@@ -37,10 +37,10 @@ function fileStatus(filename, content) {
37
37
  function writeSchemaFiles(tableMap, schemasDir) {
38
38
  const report = { created: false, touched: false, untouched: 0, files: new Set() };
39
39
  for (const schemaName of Object.keys(tableMap)) {
40
- const { name: tableName, fields, colMap, primary, generated, isView } = tableMap[schemaName];
40
+ const { name: tableName, fields, colMap, primary, generated, isView, uniqueKeys } = tableMap[schemaName];
41
41
  const filename = path.join(schemasDir, `${schemaName}.ts`);
42
42
  report.files.add(`${schemaName}.ts`);
43
- const content = generateSchemaFile(schemaName, tableName, fields, colMap, primary, generated, isView);
43
+ const content = generateSchemaFile(schemaName, tableName, fields, colMap, primary, generated, isView, uniqueKeys);
44
44
  const status = fileStatus(filename, content);
45
45
  if (status === 'created')
46
46
  report.created = true;
@@ -1 +1 @@
1
- {"version":3,"file":"mysql-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/mysql-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAkBD,oFAAoF;AACpF,UAAU,cAAc;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,0BAA0B,IAAI,qBAAqB,CAmBlE;AAyED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,cAAc,GAAG,UAAU,CAejE;AAED,wBAAsB,qBAAqB,CACzC,gBAAgB,EAAE,qBAAqB,EACvC,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CAoBvB"}
1
+ {"version":3,"file":"mysql-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/mysql-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAyBD,oFAAoF;AACpF,UAAU,cAAc;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,0BAA0B,IAAI,qBAAqB,CAmBlE;AAyED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,cAAc,GAAG,UAAU,CAejE;AAED,wBAAsB,qBAAqB,CACzC,gBAAgB,EAAE,qBAAqB,EACvC,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CA+CvB"}
@@ -117,7 +117,30 @@ export async function introspectMysqlTables(connectionConfig, schema) {
117
117
  ON t.table_schema = c.table_schema AND t.table_name = c.table_name
118
118
  WHERE c.table_schema = ?
119
119
  ORDER BY c.table_name, c.ordinal_position`, [schema]);
120
- return rows.map(mapMysqlColumnRow);
120
+ // Unique indexes (constraints included) other than the primary key. An index is left out
121
+ // whole when any part is an expression (no COLUMN_NAME) or a prefix (SUB_PART): the first
122
+ // cannot be named by a column list, the second makes only the leading characters unique —
123
+ // neither is a key an upsert can target (ADR 0021).
124
+ const [uniqueRows] = await connection.query(`SELECT s.table_name AS table_name, s.column_name AS column_name, s.index_name AS index_name
125
+ FROM information_schema.statistics s
126
+ WHERE s.table_schema = ? AND s.non_unique = 0 AND s.index_name <> 'PRIMARY'
127
+ AND NOT EXISTS (
128
+ SELECT 1 FROM information_schema.statistics x
129
+ WHERE x.table_schema = s.table_schema AND x.table_name = s.table_name
130
+ AND x.index_name = s.index_name
131
+ AND (x.column_name IS NULL OR x.sub_part IS NOT NULL)
132
+ )
133
+ ORDER BY s.table_name, s.index_name, s.seq_in_index`, [schema]);
134
+ const uniqueKeys = new Map();
135
+ for (const u of uniqueRows) {
136
+ const key = `${u.table_name}.${u.column_name}`;
137
+ uniqueKeys.set(key, [...(uniqueKeys.get(key) ?? []), u.index_name]);
138
+ }
139
+ return rows.map((row) => {
140
+ const column = mapMysqlColumnRow(row);
141
+ const indexes = uniqueKeys.get(`${column.table_name}.${column.column_name}`);
142
+ return indexes ? { ...column, unique_keys: indexes } : column;
143
+ });
121
144
  }
122
145
  finally {
123
146
  await connection.end();
@@ -1 +1 @@
1
- {"version":3,"file":"pg-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/pg-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAmBjD,wBAAgB,qBAAqB,IAAI,MAAM,CAY9C;AAcD,wBAAsB,gBAAgB,CACpC,gBAAgB,EAAE,MAAM,EACxB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CAmEvB"}
1
+ {"version":3,"file":"pg-introspect.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/pg-introspect.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAmBjD,wBAAgB,qBAAqB,IAAI,MAAM,CAY9C;AAgBD,wBAAsB,gBAAgB,CACpC,gBAAgB,EAAE,MAAM,EACxB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,EAAE,CAAC,CAsFvB"}
@@ -26,7 +26,8 @@ export async function introspectTables(connectionString, schema) {
26
26
  c.is_identity::text, c.is_generated::text,
27
27
  (pk.column_name IS NOT NULL) AS is_primary,
28
28
  (t.table_type = 'VIEW') AS is_view,
29
- c.ordinal_position::int AS ordinal_position
29
+ c.ordinal_position::int AS ordinal_position,
30
+ uk.unique_keys
30
31
  FROM information_schema.columns c
31
32
  JOIN information_schema.tables t
32
33
  ON t.table_schema = c.table_schema AND t.table_name = c.table_name
@@ -38,12 +39,28 @@ export async function introspectTables(connectionString, schema) {
38
39
  AND tc.table_schema = kcu.table_schema
39
40
  WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1
40
41
  ) pk ON pk.table_name = c.table_name AND pk.column_name = c.column_name
42
+ -- Unique indexes (constraints are backed by one) other than the primary key, over
43
+ -- plain columns only: a partial index (indpred) or one on an expression (indexprs)
44
+ -- cannot be named by a column list, so it is no upsert key (ADR 0021).
45
+ LEFT JOIN (
46
+ SELECT tbl.relname::text AS table_name, att.attname::text AS column_name,
47
+ array_agg(idx.relname::text ORDER BY idx.relname) AS unique_keys
48
+ FROM pg_index i
49
+ JOIN pg_class idx ON idx.oid = i.indexrelid
50
+ JOIN pg_class tbl ON tbl.oid = i.indrelid
51
+ JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
52
+ JOIN pg_attribute att ON att.attrelid = tbl.oid AND att.attnum = ANY(i.indkey)
53
+ WHERE i.indisunique AND NOT i.indisprimary
54
+ AND i.indpred IS NULL AND i.indexprs IS NULL
55
+ AND ns.nspname = $1
56
+ GROUP BY tbl.relname, att.attname
57
+ ) uk ON uk.table_name = c.table_name AND uk.column_name = c.column_name
41
58
  WHERE c.table_schema = $1
42
59
  UNION ALL
43
60
  SELECT mc.relname::text, ma.attname::text, mt.typname::text,
44
61
  NULL::text, CASE WHEN ma.attnotnull THEN 'NO' ELSE 'YES' END,
45
62
  'NO'::text, 'NEVER'::text,
46
- false, true, ma.attnum::int
63
+ false, true, ma.attnum::int, NULL::text[]
47
64
  FROM pg_class mc
48
65
  JOIN pg_namespace mn ON mn.oid = mc.relnamespace
49
66
  JOIN pg_attribute ma
@@ -55,6 +72,7 @@ export async function introspectTables(connectionString, schema) {
55
72
  // The query's own columns are spelled out rather than spread: `is_identity` and
56
73
  // `is_generated` arrive as the standard's string enums and `ordinal_position` only exists
57
74
  // to order the union, so none of the three belongs in the `ColumnInfo` handed back.
75
+ // `unique_keys` is NULL for a column in no unique index.
58
76
  return result.rows.map((row) => ({
59
77
  table_name: row.table_name,
60
78
  column_name: row.column_name,
@@ -67,6 +85,7 @@ export async function introspectTables(connectionString, schema) {
67
85
  // mandatory field and the table cannot be inserted into at all.
68
86
  ...(row.is_identity === 'YES' ? { is_auto_increment: true } : {}),
69
87
  ...(row.is_generated === 'ALWAYS' ? { is_generated: true } : {}),
88
+ ...(row.unique_keys?.length ? { unique_keys: row.unique_keys } : {}),
70
89
  }));
71
90
  }
72
91
  finally {
@@ -30,5 +30,5 @@ export declare function applyExcludeTables(tableMap: TableMap, excludeTables?: s
30
30
  * Returns the excluded columns as `table.column`, in map order.
31
31
  */
32
32
  export declare function applyExcludeColumns(tableMap: TableMap, excludeColumns?: string[]): string[];
33
- export declare function generateSchemaFile(schemaName: string, tableName: string, fields: Record<string, string>, colMap: Record<string, string>, primary?: string[], generated?: string[], isView?: boolean): string;
33
+ export declare function generateSchemaFile(schemaName: string, tableName: string, fields: Record<string, string>, colMap: Record<string, string>, primary?: string[], generated?: string[], isView?: boolean, uniqueKeys?: string[][]): string;
34
34
  //# 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;;GAEG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IACV,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,GACA,MAAM,CA6DR;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CA0C1D;AAaD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,EAAE,aAAa,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAgBzF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,cAAc,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAiD3F;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,EAClB,SAAS,CAAC,EAAE,MAAM,EAAE,EACpB,MAAM,CAAC,EAAE,OAAO,GACf,MAAM,CAoDR"}
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;;GAEG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IACV,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,GACA,MAAM,CA6DR;AAcD,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CAwD1D;AAaD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,EAAE,aAAa,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAgBzF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,cAAc,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAmD3F;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,EAClB,SAAS,CAAC,EAAE,MAAM,EAAE,EACpB,MAAM,CAAC,EAAE,OAAO,EAChB,UAAU,CAAC,EAAE,MAAM,EAAE,EAAE,GACtB,MAAM,CAuDR"}