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.
- package/AGENTS.md +24 -111
- package/AGENTS_BACKEND.md +271 -1381
- package/AGENTS_FRONTEND.md +72 -47
- package/BREAKING_CHANGES.md +129 -0
- package/CHANGELOG.md +86 -1
- package/README.md +1 -1
- package/dist/bin/generate-schema.js +2 -2
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +24 -1
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +21 -2
- package/dist/lib/cli/schema-codegen.d.ts +1 -1
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +31 -3
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +187 -60
- package/dist/lib/dialect.d.ts +15 -10
- package/dist/lib/dialect.d.ts.map +1 -1
- package/dist/lib/dialect.js +12 -31
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +2 -1
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +2 -1
- package/dist/lib/engine/search/aggregations.d.ts +7 -0
- package/dist/lib/engine/search/aggregations.d.ts.map +1 -1
- package/dist/lib/engine/search/aggregations.js +8 -7
- package/dist/lib/engine/write-helpers.d.ts +13 -1
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +35 -0
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +41 -0
- package/dist/types/cli.d.ts +11 -0
- package/dist/types/cli.d.ts.map +1 -1
- package/dist/types/schema.d.ts +7 -0
- package/dist/types/schema.d.ts.map +1 -1
- package/docs/adr/0021-upsert-targets-a-verified-unique-key.md +153 -0
- package/docs/adr/README.md +1 -0
- 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;
|
|
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,
|
|
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)
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -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
|
package/dist/types/cli.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/types/schema.d.ts
CHANGED
|
@@ -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.
|
package/docs/adr/README.md
CHANGED
|
@@ -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
|
|