Skip to content

Structured SQL errors

Every /v1/query and /v1/batch failure ships a structured errorDetail next to the human-readable error string. Agents can branch on kind and self-correct without parsing the message.

interface SqlErrorDetail {
kind:
| "unique_violation"
| "not_null_violation"
| "fk_violation"
| "check_violation"
| "unknown_table"
| "unknown_column"
| "syntax_error"
| "type_mismatch"
| "database_locked"
| "too_many_params"
| "readonly"
| "unknown";
message: string;
table?: string;
column?: string;
columns?: Array<{ table: string; column: string }>;
constraint?: string;
near?: string;
hint?: string;
}

hint is verb-aware. A unique_violation on INSERT suggests ON CONFLICT DO UPDATE; on UPDATE it just states the conflict.

PerSQLError.detail carries the envelope:

import { PerSQL, PerSQLError } from "@persql/sdk";
try {
await db.query("INSERT INTO users(email) VALUES (?)", ["taken@x.com"]);
} catch (e) {
if (e instanceof PerSQLError && e.detail?.kind === "unique_violation") {
// Switch to UPDATE without re-prompting the model.
await db.query("UPDATE users SET ... WHERE email = ?", ["taken@x.com"]);
}
}

The query and batch tools return JSON-shaped errors when an envelope is available:

{
"error": "UNIQUE constraint failed: users.email",
"errorDetail": {
"kind": "unique_violation",
"table": "users",
"column": "email",
"hint": "Row with this key already exists — INSERT ... ON CONFLICT DO UPDATE or use UPDATE."
}
}

A batch failure is reported by SQLite without saying which statement broke. The server picks a culprit by matching the table referenced in the error against the statements in the batch and attaches the envelope to that one. If no match, it falls back to the first mutating statement.