fastify-auto-sqlapi 0.2.4 → 0.4.0

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 (54) hide show
  1. package/AGENTS.md +24 -111
  2. package/AGENTS_BACKEND.md +282 -1332
  3. package/AGENTS_FRONTEND.md +70 -47
  4. package/BREAKING_CHANGES.md +131 -0
  5. package/CHANGELOG.md +139 -0
  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/write-helpers.d.ts +13 -1
  25. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  26. package/dist/lib/engine/write-helpers.js +35 -0
  27. package/dist/lib/schema/helpers.d.ts +7 -0
  28. package/dist/lib/schema/helpers.d.ts.map +1 -1
  29. package/dist/lib/schema/helpers.js +18 -0
  30. package/dist/lib/schema/insert.d.ts.map +1 -1
  31. package/dist/lib/schema/insert.js +3 -1
  32. package/dist/lib/table-helpers.d.ts.map +1 -1
  33. package/dist/lib/table-helpers.js +41 -0
  34. package/dist/lib/tenant.d.ts +6 -0
  35. package/dist/lib/tenant.d.ts.map +1 -1
  36. package/dist/lib/tenant.js +16 -0
  37. package/dist/routes/auto/bulk-delete.routes.js +1 -1
  38. package/dist/routes/auto/bulk-upsert.routes.js +1 -1
  39. package/dist/routes/auto/delete.routes.js +1 -1
  40. package/dist/routes/auto/get.routes.js +1 -1
  41. package/dist/routes/auto/insert.routes.js +1 -1
  42. package/dist/routes/auto/route-helpers.d.ts +5 -2
  43. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  44. package/dist/routes/auto/route-helpers.js +1 -1
  45. package/dist/routes/auto/search.routes.js +3 -3
  46. package/dist/routes/auto/update.routes.js +1 -1
  47. package/dist/types/cli.d.ts +11 -0
  48. package/dist/types/cli.d.ts.map +1 -1
  49. package/dist/types/schema.d.ts +7 -0
  50. package/dist/types/schema.d.ts.map +1 -1
  51. package/docs/adr/0020-no-conditional-or-mass-update-route.md +68 -0
  52. package/docs/adr/0021-upsert-targets-a-verified-unique-key.md +153 -0
  53. package/docs/adr/README.md +2 -0
  54. package/package.json +4 -4
@@ -0,0 +1,153 @@
1
+ # 0021. An upsert targets a unique key verified at startup — MySQL and MariaDB upsert by key, not by `ON DUPLICATE KEY`
2
+
3
+ - **Status**: accepted
4
+ - **Date**: 2026-09-27
5
+
6
+ ## Context
7
+
8
+ `upsertMap` turns an insert — main row, secondaries, every bulk item — into an upsert on the
9
+ columns it names. Nothing checked those columns. `buildUpsertRule` accepts any field list, and
10
+ the README's headline example relies on that freedom: a client that knows no ids syncs
11
+ customers by `email` and their tags by `(customerId, tag)`.
12
+
13
+ Investigating a bug report on bulk upserts showed that the two dialects do not agree on what
14
+ such a rule means, and that MySQL can write to the wrong row without any error:
15
+
16
+ | Declared key | PostgreSQL (`ON CONFLICT (key)`) | MySQL (`ON DUPLICATE KEY UPDATE`) |
17
+ |---|---|---|
18
+ | has no unique index | every write fails: *no unique or exclusion constraint matching the ON CONFLICT specification* | never conflicts: every upsert inserts, duplicates pile up in silence |
19
+ | is unique, and the row carries another unique key matching a **different** row | unique violation on the other key | updates whichever row conflicts first — possibly the other one, in silence |
20
+ | is unique, row updated through it, key is not the PK | `RETURNING` names the row | `insertId` names no updated row; the PK returned was `insertId + n`, a wrong or non-existent id, and secondaries were attached to it |
21
+
22
+ MySQL's `ON DUPLICATE KEY UPDATE` has no conflict target at all: it fires on **any** unique
23
+ index of the table, the primary key included. The columns declared in `upsertMap` do not
24
+ select what MySQL matches on. A PK lookup by the declared key (the first fix attempted) is
25
+ itself ambiguous when the key is not actually unique.
26
+
27
+ The two failure modes that matter are the silent ones — duplicates and wrong-row writes on
28
+ MySQL. PostgreSQL already fails loudly in every case above, just late (at the first write) and
29
+ opaquely (a raw 500).
30
+
31
+ ## Decision
32
+
33
+ **1. The Schema records the table's unique keys.** The CLI introspects every unique
34
+ constraint and unique index — `pg_index.indisunique` on PostgreSQL,
35
+ `information_schema.STATISTICS` with `NON_UNIQUE = 0` on MySQL — and emits them in the
36
+ generated Schema as `uniqueKeys: [['email'], ['customerId', 'tag']]` (camelCase fields, the
37
+ primary key excluded: it is `primaryKey`). The property is emitted even when empty, so its
38
+ absence means "not generated by a CLI that knows about unique keys", never "no unique keys".
39
+ Left out:
40
+
41
+ - **partial and expression indexes** (`WHERE deleted_at IS NULL`, `lower(email)`): a column
42
+ list cannot express them, and PostgreSQL does not accept one as the target of
43
+ `ON CONFLICT (cols)`;
44
+ - **keys with a column removed by `excludeColumns`**: the API can never write that column, so
45
+ it can never name the key.
46
+
47
+ **2. `defineTable` refuses an `upsertMap` rule that is not a verified unique key.** For every
48
+ rule — the table's own schema and every write-join schema — the declared field set must equal
49
+ the Schema's `primaryKey` or one of its `uniqueKeys`, as a set: same fields, any order, nothing
50
+ extra (the rule PostgreSQL itself applies to a conflict target). It is **fail-closed**: a rule
51
+ that cannot be verified — a hand-written Schema, or one generated before `uniqueKeys` existed,
52
+ whose rule is not its `primaryKey` — is refused with a message naming the table and telling to
53
+ regenerate. The check reads the **Schema**, not a table config: a child table written only
54
+ through a write join — `content_info(content_id, lang_id, …)` under `content` — needs no
55
+ `defineTable` of its own; its rule sits in the parent's `upsertMap` and is verified against
56
+ the child Schema's `primaryKey` / `uniqueKeys`. A view has no keys, so a view never takes an
57
+ upsert rule. The check lives in
58
+ `defineTable` with every other config check; a `DbTables` entry written as a plain object does
59
+ not go through it, as it already skips the others.
60
+
61
+ **3. Every upserted record must carry its key — `400` otherwise.**
62
+
63
+ - *The key is a natural key* (not the primary key): every key field must be present and not
64
+ `null`. A `NULL` never conflicts, so such a record could only insert, and could not be found
65
+ again by its key.
66
+ - *The key is the primary key*: a record **without** any PK field is a plain insert with a
67
+ generated key — the normal way to mix new and existing rows in one bulk. A PK present only
68
+ in part, or `null`, is a `400`.
69
+ - **Two items of one request with the same key** are a `400`: PostgreSQL refuses to affect a
70
+ row twice in one statement, MySQL would let the last one win. Neither is a meaningful
71
+ request.
72
+
73
+ The check runs on the record as it reaches the database — after hooks, tenant injection and
74
+ the FK auto-fill of secondaries.
75
+
76
+ **4. Execution: the same contract, one implementation per dialect.**
77
+
78
+ - **PostgreSQL** keeps `INSERT … ON CONFLICT (key) DO UPDATE`: once the key is a verified
79
+ unique key, it targets exactly that key, and a collision on any other unique index is an
80
+ error — which is the contract. One statement per chunk, as before.
81
+ - **MySQL and MariaDB** stop using `ON DUPLICATE KEY UPDATE` — it has no conflict target on
82
+ either — and upsert by key explicitly, per chunk:
83
+ 1. **probe**: which items already exist, one `SELECT` for the chunk (a `UNION ALL` part per
84
+ item, tagged with its position, so value comparison stays the database's). More than one
85
+ row for one key means the key is not unique in the database (the index was dropped after
86
+ generation): the chunk is refused before anything is written. The tenant conflict guard
87
+ stays its own query, as on PostgreSQL: it lives in the engine, which owns the tenant;
88
+ 2. **insert** the new items in one multi-row `INSERT`;
89
+ 3. **update** the existing items by their key in one statement — each column a `CASE` over
90
+ the keys, the `WHERE` naming the same keys — so it can only reach the rows those keys
91
+ name. The number of rows matched must equal the number of items; a row deleted since the
92
+ probe is an error, not a silently skipped write. This relies on the driver reporting
93
+ matched rows, which `mysql2` does by default (`CLIENT_FOUND_ROWS`).
94
+
95
+ A collision on another unique index is then a duplicate-key error there too. PKs come from
96
+ the probe for existing rows and from the insert for new ones, so no PK lookup after the
97
+ write is needed.
98
+
99
+ The observable behaviour is the same on both dialects, and one set of integration tests, run
100
+ against both, is what pins it.
101
+
102
+ ## Alternatives considered
103
+
104
+ - **Document the pitfalls, change nothing** — rejected: the MySQL failure modes are silent
105
+ data corruption, which no amount of documentation makes acceptable for a library that
106
+ advertises upsert-by-natural-key as a feature.
107
+ - **Error only when the PK lookup finds several rows** — rejected as insufficient: it catches
108
+ a non-unique key after the write, and does nothing for `ON DUPLICATE KEY` matching a row
109
+ through another unique key.
110
+ - **Reject any record carrying the columns of another unique key** — rejected: it forbids the
111
+ most common upsert of all, by `id` on a table with a unique `email`.
112
+ - **Probe every other unique key and answer `409` when one matches a different row** — works on
113
+ both dialects, but adds a query per key per chunk on PostgreSQL, which needs none: its
114
+ `ON CONFLICT (key)` already errors on those collisions.
115
+ - **Change the signature to `upsert(key, fields)`** — clearer intent, and it would make the
116
+ key checks of point 3 schema validation. It does not make anything safer on its own: MySQL
117
+ still matches on any unique index while `ON DUPLICATE KEY` is used, the key must still be a
118
+ verified unique key, and new rows with a generated PK still have no key to pass. A breaking
119
+ change for every client, for clarity only — deferred; it can come in a major release on top
120
+ of this decision without changing it.
121
+ - **Explicit upsert on PostgreSQL too** — one implementation instead of two, but one or two
122
+ extra queries per chunk on the dialect whose native statement already has the right
123
+ semantics.
124
+ - **Check the rules at plugin registration** — would also cover plain-object `DbTables`
125
+ entries, but splits config validation across two places; every other rule is enforced by
126
+ `defineTable`.
127
+
128
+ ## Consequences
129
+
130
+ - **Breaking.** An `upsertMap` rule on columns without a unique index now fails at startup,
131
+ and so does any rule on a Schema that predates `uniqueKeys` unless it names the primary key:
132
+ regenerate the schemas. Records missing their key, and duplicate keys in one request, become
133
+ `400`s. Migration notes go in `BREAKING_CHANGES.md`.
134
+ - **MySQL and MariaDB cost more.** An upsert is two to three statements per chunk (probe,
135
+ insert, update) instead of one, plus the tenant guard where there is one, as before;
136
+ PostgreSQL is unchanged. The count does not grow with the number of
137
+ rows in the chunk.
138
+ - **A collision on another unique index is a database error** on both dialects — a sanitized
139
+ 500 ([ADR 0013](./0013-sanitized-db-errors.md)). A descriptive `409` can be added later
140
+ without changing this decision.
141
+ - **There is a window between probe and write on MySQL and MariaDB.** A concurrent insert of the same key
142
+ fails with a duplicate-key error; a concurrent delete fails the matched-row check. Both are
143
+ errors, never a wrong write — consistent with non-transactional bulk
144
+ ([ADR 0003](./0003-bulk-not-transactional.md)).
145
+ - **A nullable unique column cannot be a natural upsert key for rows where it is `NULL`**: those
146
+ rows are inserted through a plain insert, or the column made `NOT NULL`.
147
+ - The generated Schema is the source of truth for keys, as it is for columns
148
+ ([ADR 0017](./0017-the-schema-is-the-contract.md)): an index added or dropped in the database
149
+ reaches the API when the schemas are regenerated. A dropped index is still caught at runtime
150
+ by the probe on MySQL/MariaDB and by `ON CONFLICT` itself on PostgreSQL.
151
+ - `QueryClient.bulkInsertOrUpdate` / `insertOrUpdate` get the upsert by key too, so a
152
+ custom route calling them directly is protected by the probe — but not by the startup check
153
+ or the `400`s, which need a table config.
@@ -34,6 +34,8 @@ not by editing history.
34
34
  | [0017](./0017-the-schema-is-the-contract.md) | The database schema is the API contract — by default, and only by default |
35
35
  | [0018](./0018-one-seam-to-the-http-framework.md) | The HTTP framework is reachable through one seam — `ApiRequest` |
36
36
  | [0019](./0019-column-exclusion-is-generation-time-config.md) | Removing a column from the API is generation-time config — `excludeColumns`, never a Schema edit |
37
+ | [0020](./0020-no-conditional-or-mass-update-route.md) | No conditional or mass update route — a predicate write is a custom route |
38
+ | [0021](./0021-upsert-targets-a-verified-unique-key.md) | An upsert targets a unique key verified at startup — MySQL and MariaDB upsert by key, not by `ON DUPLICATE KEY` |
37
39
 
38
40
  ## When to write one
39
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify-auto-sqlapi",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "description": "Fastify plugin for automatic CRUD API generation from PostgreSQL, MySQL and MariaDB tables",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -83,20 +83,20 @@
83
83
  "homepage": "https://github.com/LucaRainone/fastify-auto-sqlapi#readme",
84
84
  "dependencies": {
85
85
  "@sinclair/typebox": "^0.34.14",
86
- "fastify-plugin": "^5.1.0",
86
+ "fastify-plugin": "^6.0.0",
87
87
  "node-condition-builder": "^1.2.0"
88
88
  },
89
89
  "devDependencies": {
90
90
  "@eslint/js": "^10.0.1",
91
91
  "@fastify/postgres": "^6.0.2",
92
92
  "@fastify/swagger": "^9.0.0",
93
- "@fastify/swagger-ui": "^5.0.0",
93
+ "@fastify/swagger-ui": "^6.1.1",
94
94
  "@types/node": "^22.13.4",
95
95
  "@types/pg": "^8.11.11",
96
96
  "dependency-cruiser": "^18.1.0",
97
97
  "eslint": "^10.8.0",
98
98
  "eslint-plugin-sonarjs": "^4.2.0",
99
- "fastify": "5.8.5",
99
+ "fastify": "5.12.4",
100
100
  "fuse.js": "^7.5.0",
101
101
  "jscpd": "^5.0.14",
102
102
  "knip": "^6.31.0",