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,6 +1,18 @@
1
1
  import type { ApiRequest } from '../../types/request.js';
2
2
  import type { QueryClient } from '../db.js';
3
- import type { ITable, DbTables, TenantContext } from '../../types.js';
3
+ import type { ITable, DbTables, TenantContext, SchemaDefinition } from '../../types.js';
4
+ /**
5
+ * Every upserted record must carry its key (ADR 0021), checked on the record as it reaches the
6
+ * database — after hooks, tenant injection and the FK auto-fill of secondaries:
7
+ * - a natural key needs every field present and not null: a NULL never conflicts, so the row
8
+ * could only be inserted and never found again by its key;
9
+ * - on the primary key, a record naming no PK field at all is a plain insert with a generated
10
+ * key; one naming it only in part, or as null, is refused like a natural key;
11
+ * - two records of one request with the same key are refused: PostgreSQL cannot affect a row
12
+ * twice in one statement, MySQL would let the last one win.
13
+ * Throws 400 before any SQL runs. Column names and record keys are DB column names.
14
+ */
15
+ export declare function assertUpsertKeys(schema: SchemaDefinition, conflictCols: string[], pkCols: string[], records: Record<string, unknown>[]): void;
4
16
  export declare function findSecondaryTableConf(dbTables: DbTables, joinTableName: string): ITable | undefined;
5
17
  /**
6
18
  * The context a per-alias write pass runs in. Shared by `processSecondaries` and
@@ -1 +1 @@
1
- {"version":3,"file":"write-helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/write-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAEzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAU5C,OAAO,KAAK,EACV,MAAM,EACN,QAAQ,EAGR,aAAa,EACd,MAAM,gBAAgB,CAAC;AAmCxB,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,GACpB,MAAM,GAAG,SAAS,CAKpB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,QAAQ,CAAC;IACnB,sFAAsF;IACtF,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,wFAAwF;IACxF,OAAO,EAAE,UAAU,CAAC;CACrB;AAmCD,UAAU,UAAU;IAClB,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,UAAU,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,EACvD,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,GACnC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,CAgB7E;AAkCD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,aAAa,EACnB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACrD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CA0DpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,aAAa,EACnB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACnD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAoBpD"}
1
+ {"version":3,"file":"write-helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/write-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAEzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAW5C,OAAO,KAAK,EACV,MAAM,EACN,QAAQ,EAGR,aAAa,EACb,gBAAgB,EACjB,MAAM,gBAAgB,CAAC;AAExB;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,gBAAgB,EACxB,YAAY,EAAE,MAAM,EAAE,EACtB,MAAM,EAAE,MAAM,EAAE,EAChB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GACjC,IAAI,CA0BN;AAmCD,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,GACpB,MAAM,GAAG,SAAS,CAKpB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,QAAQ,CAAC;IACnB,sFAAsF;IACtF,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,wFAAwF;IACxF,OAAO,EAAE,UAAU,CAAC;CACrB;AAmCD,UAAU,UAAU;IAClB,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,UAAU,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,EACvD,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,GACnC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,CAgB7E;AAkCD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,aAAa,EACnB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACrD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CA8DpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,aAAa,EACnB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACnD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAoBpD"}
@@ -1,7 +1,41 @@
1
1
  import { camelcaseObject, snakecaseRecord } from '../naming.js';
2
2
  import { runValidation } from './validate.js';
3
3
  import { removeWriteExcluded } from '../write-access.js';
4
+ import { httpError } from '../errors.js';
4
5
  import { tenantForTable, buildTenantRowGuard, enforceTenantOnWrites, assertTenantOwnsConflicts, tenantImmutableColumns, } from '../tenant.js';
6
+ /**
7
+ * Every upserted record must carry its key (ADR 0021), checked on the record as it reaches the
8
+ * database — after hooks, tenant injection and the FK auto-fill of secondaries:
9
+ * - a natural key needs every field present and not null: a NULL never conflicts, so the row
10
+ * could only be inserted and never found again by its key;
11
+ * - on the primary key, a record naming no PK field at all is a plain insert with a generated
12
+ * key; one naming it only in part, or as null, is refused like a natural key;
13
+ * - two records of one request with the same key are refused: PostgreSQL cannot affect a row
14
+ * twice in one statement, MySQL would let the last one win.
15
+ * Throws 400 before any SQL runs. Column names and record keys are DB column names.
16
+ */
17
+ export function assertUpsertKeys(schema, conflictCols, pkCols, records) {
18
+ const isPrimaryKey = conflictCols.length === pkCols.length
19
+ && conflictCols.every((c) => pkCols.includes(c));
20
+ const fieldOf = (col) => Object.keys(schema.fields).find((f) => schema.col(f) === col) ?? col;
21
+ const rule = `Upsert on "${schema.tableName}" by (${conflictCols.map(fieldOf).join(', ')})`;
22
+ const seen = new Map();
23
+ records.forEach((record, i) => {
24
+ if (isPrimaryKey && conflictCols.every((c) => record[c] === undefined))
25
+ return;
26
+ const missing = conflictCols.filter((c) => record[c] == null);
27
+ if (missing.length) {
28
+ throw httpError(400, `${rule}: the row at index ${i} has no value for ${missing.map(fieldOf).join(', ')} — ` +
29
+ `an upserted row must carry its whole key`);
30
+ }
31
+ const key = JSON.stringify(conflictCols.map((c) => record[c]));
32
+ const first = seen.get(key);
33
+ if (first !== undefined) {
34
+ throw httpError(400, `${rule}: the rows at index ${first} and ${i} have the same key`);
35
+ }
36
+ seen.set(key, i);
37
+ });
38
+ }
5
39
  /**
6
40
  * Resolve the write join an alias names, or undefined when the table does not declare it.
7
41
  *
@@ -144,6 +178,7 @@ export function processSecondaries(pass, secondaries) {
144
178
  let pkRows;
145
179
  if (upsertKeys) {
146
180
  const conflictCols = upsertKeys.map((k) => joinSchema.col(k));
181
+ assertUpsertKeys(joinSchema, conflictCols, (joinSchema.primaryKey ?? [childPk].flat()).map((f) => joinSchema.col(f)), preparedRecords);
147
182
  // A conflicting upsert must not overwrite — or re-parent — a child row owned by
148
183
  // another tenant, which the ON CONFLICT key alone would happily match.
149
184
  await assertTenantOwnsConflicts(db, childTenant, joinSchema.tableName, conflictCols, preparedRecords);
@@ -1 +1 @@
1
- {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EAEP,MAAM,aAAa,CAAC;AAcrB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAWX;AAiSD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
1
+ {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EAEP,MAAM,aAAa,CAAC;AAcrB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAYX;AAmVD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
@@ -55,6 +55,7 @@ export function defineTable(config) {
55
55
  validateWriteExclude(config);
56
56
  validateTenantScope(config);
57
57
  validateTenantColumnNames(config);
58
+ validateUpsertMap(config);
58
59
  return config;
59
60
  }
60
61
  /**
@@ -255,6 +256,46 @@ function validateWriteExclude(config) {
255
256
  }
256
257
  }
257
258
  }
259
+ const formatKey = (fields) => `(${fields.join(', ')})`;
260
+ const sameFieldSet = (a, b) => a.length === b.length && a.every((f) => b.includes(f));
261
+ /**
262
+ * An `upsertMap` rule must name a unique key the database enforces — the Schema's
263
+ * `primaryKey` or one of its `uniqueKeys`, as a set: same fields, any order, nothing extra
264
+ * (ADR 0021). Anything else fails every write on PostgreSQL, and on MySQL duplicates rows or
265
+ * updates a row matched through another key, in silence.
266
+ *
267
+ * Fail-closed: a rule the Schema cannot vouch for is refused. The Schema is what is read, not
268
+ * a table config, so a child written only through a write join needs no `defineTable`.
269
+ */
270
+ function validateUpsertMap(config) {
271
+ for (const [schema, keys] of config.upsertMap ?? []) {
272
+ const problem = upsertKeyProblem(schema, keys);
273
+ if (problem) {
274
+ throw new Error(`defineTable: upsertMap rule on '${schema.tableName}' ${formatKey(keys)} ${problem} (ADR 0021).`);
275
+ }
276
+ }
277
+ }
278
+ /** Why `keys` is not a verified unique key of `schema`, or null when it is one. */
279
+ function upsertKeyProblem(schema, keys) {
280
+ if (schema.isView)
281
+ return 'targets a view — a view has no unique key to upsert on';
282
+ const primaryKey = schema.primaryKey ?? [];
283
+ if (primaryKey.length && sameFieldSet(keys, primaryKey))
284
+ return null;
285
+ if (schema.uniqueKeys === undefined) {
286
+ return (`cannot be verified: the Schema of '${schema.tableName}' carries no uniqueKeys` +
287
+ `${primaryKey.length ? '' : ' and no primaryKey'} — regenerate the schemas with ` +
288
+ `sqlapi-generate-schema, an upsert must target a unique key the database enforces`);
289
+ }
290
+ if (schema.uniqueKeys.some((unique) => sameFieldSet(keys, unique)))
291
+ return null;
292
+ const available = [
293
+ ...(primaryKey.length ? [`${formatKey(primaryKey)} primary key`] : []),
294
+ ...schema.uniqueKeys.map(formatKey),
295
+ ];
296
+ return (`is not a unique key of the table. An upsert must target exactly one: ` +
297
+ `${available.length ? available.join(', ') : 'the table has none'}`);
298
+ }
258
299
  function validateComputedFields(config) {
259
300
  const computed = config.computedFields;
260
301
  if (!computed)
@@ -53,6 +53,12 @@ export interface ColumnInfo {
53
53
  * simple enough for the engine to make updatable accepts a write.
54
54
  */
55
55
  is_view?: boolean;
56
+ /**
57
+ * Names of the unique indexes (constraints included) this column belongs to, the primary key
58
+ * excluded. Only indexes over plain columns with no predicate: a partial or expression index
59
+ * cannot be named by a column list, so it cannot be an upsert key (ADR 0021).
60
+ */
61
+ unique_keys?: string[];
56
62
  }
57
63
  export interface TableMap {
58
64
  [schemaName: string]: {
@@ -65,6 +71,11 @@ export interface TableMap {
65
71
  generated: string[];
66
72
  /** True when this relation is a view (or a materialized view), not a base table. */
67
73
  isView: boolean;
74
+ /**
75
+ * Unique keys other than the primary key, each as its fields (camelCase). Always set by
76
+ * `buildTableMap`; absent only on a map built by hand.
77
+ */
78
+ uniqueKeys?: string[][];
68
79
  };
69
80
  }
70
81
  //# sourceMappingURL=cli.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/types/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,QAAQ;IACvB,CAAC,UAAU,EAAE,MAAM,GAAG;QACpB,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,iFAAiF;QACjF,OAAO,EAAE,MAAM,EAAE,CAAC;QAClB,mFAAmF;QACnF,SAAS,EAAE,MAAM,EAAE,CAAC;QACpB,oFAAoF;QACpF,MAAM,EAAE,OAAO,CAAC;KACjB,CAAC;CACH"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/types/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,MAAM,WAAW,QAAQ;IACvB,CAAC,UAAU,EAAE,MAAM,GAAG;QACpB,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,iFAAiF;QACjF,OAAO,EAAE,MAAM,EAAE,CAAC;QAClB,mFAAmF;QACnF,SAAS,EAAE,MAAM,EAAE,CAAC;QACpB,oFAAoF;QACpF,MAAM,EAAE,OAAO,CAAC;QAChB;;;WAGG;QACH,UAAU,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC;KACzB,CAAC;CACH"}
@@ -15,6 +15,13 @@ export interface SchemaDefinition<T = Record<string, TSchema>> {
15
15
  * turn a client mistake into a driver error.
16
16
  */
17
17
  generatedFields?: string[];
18
+ /**
19
+ * The table's unique keys other than the primary key, each as its fields (camelCase), as
20
+ * introspected from the DB. Set by generated schemas — always, even when empty, so an absent
21
+ * property means "not known" rather than "none". `defineTable` verifies every `upsertMap`
22
+ * rule against `primaryKey` and these (ADR 0021).
23
+ */
24
+ uniqueKeys?: string[][];
18
25
  /**
19
26
  * True when the underlying relation is a view. Set by generated schemas.
20
27
  *
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/types/schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAE1D,MAAM,WAAW,gBAAgB,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC3D,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,MAAM,EAAE,CAAC,CAAC;IACV,UAAU,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,OAAO,CAAC;IAC3B,4FAA4F;IAC5F,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB"}
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/types/schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAE1D,MAAM,WAAW,gBAAgB,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC3D,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,MAAM,EAAE,CAAC,CAAC;IACV,UAAU,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,OAAO,CAAC;IAC3B,4FAA4F;IAC5F,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC;IACxB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB"}
@@ -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.
@@ -35,6 +35,7 @@ not by editing history.
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
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` |
38
39
 
39
40
  ## When to write one
40
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify-auto-sqlapi",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",