Answering 409 with the right constraint¶
The database says why it refused a write — in the driver's prose, not your
application's. parseIntegrityError reads that prose back into a structure:
import { BaseRepository, parseIntegrityError } from "tempest-db-js";
try {
await users.create({ email, tenantId });
} catch (error) {
const failure = parseIntegrityError(error, User); // (1)!
if (failure?.violation === "unique" && failure.columns.includes("email")) {
return json({ code: "EMAIL_TAKEN", field: "email" }, { status: 409 });
}
throw error; // (2)!
}
- The model is optional; passing it translates database names back to properties
(
idempotency_key→idempotencyKey) for anyone usingnaming = "snake_case". - Not an integrity violation ⇒
null. Rethrowing is right: swallowing here would hide a syntax error, a deadlock, a dropped connection.
What comes back¶
| Field | Carries |
|---|---|
violation |
"unique" · "foreignKey" · "notNull" · "check" · "exclusion" |
constraint |
the name, when the database reports one (PostgreSQL does, SQLite does not) |
table |
the table, when reported |
columns |
the covered columns — all of them on a composite constraint |
detail |
the driver's raw message, for logs |
What each database gives you¶
The same failure arrives in two different shapes, and the difference is not cosmetic:
MySQL returns null
MySQL is outside the project's active scope, so an ER_DUP_ENTRY returns null
rather than a guess. null means "I don't know", not "it wasn't a violation" —
handle it by rethrowing, like any unknown error.
A composite constraint comes back whole
columns carries every column of a composite unique, which is exactly what
decides whether the user-facing message talks about the email or about the
(email, tenant) pair.
Recap¶
parseIntegrityError(error, Model?)→IntegrityFailure | null.- It follows the
causechain, so this package'sQueryExecutionErroris no obstacle. - Pass the model to get property names instead of column names.
null⇒ rethrow. 🚀