Errors
Read an OkmError, branch on code or kind, map it with toHttp, and know what retryable means.
A failure from the client is an OkmError. Import it from okmodel. The field list, kinds, categories, and every OKM code live on Errors. This page is what to do once you have one.
Reading one
| Field | What it is for |
|---|---|
code | An OKM1xxx guard, or the kind name when that kind has no number. Stable to branch on. |
kind | Kind name (unique, serialization, outcome_unknown, …). Selects the category. |
category | One of input, conflict, not_found, forbidden, transient, internal. Drives toHttp status. |
retryable | Whether a declared retry may run this error again. See Retry. |
fix | { summary, suggestion? }. What to change. suggestion is a nearer accepted name when the input was a typo. |
issues | Validation checks (path + message key). Present on OKM1200 only. |
There is no docs field on the error. Open the matching row on Errors, or docs/errors/<code>.md in the installed package.
import { OkmError } from "okmodel";
export function describe(error: unknown) {
const err = OkmError.from(error);
return {
code: err.code,
kind: err.kind,
category: err.category,
retryable: err.retryable,
fix: err.fix.summary,
issues: err.issues,
};
}summary is the same string as message. fields() maps each named column to a reason and never includes row values. log() is the structured record for a log line.
Branching
Branch on code or kind, not on message. Message text can name values the database sent and is not a contract.
import { OkmError } from "okmodel";
export function taken(error: unknown): boolean {
return OkmError.is(error, "unique");
}
export function badLimit(error: unknown): boolean {
return error instanceof OkmError && error.code === "OKM1101";
}
export function label(error: unknown): string {
return OkmError.from(error).match({
conflict: (err) => `conflict:${err.code}`,
_: (err) => `other:${err.code}`,
});
}
export function reasons(error: unknown) {
return OkmError.from(error).fields();
}A duplicate unique on notes.title prints:
{
code: "unique",
kind: "unique",
category: "conflict",
retryable: false,
isUnique: true,
fields: { title: "title" },
matched: "conflict:unique",
fix: "Change the conflicting value, or update the existing row."
}OkmError.is(error, kind) and OkmError.is(error, kind, table) narrow the same way. error.match({ input, conflict, _, … }) dispatches by category. fields() returns the column map once; later calls reuse it.
toHttp()
toHttp() returns { status, body } for an HTTP response. The body is { code, reason, fields }. reason is the summary. Row values stay out.
| Category | Default status |
|---|---|
input | 422 |
conflict | 409 |
not_found | 404 |
forbidden | 403 |
transient | 503 |
internal | 500 |
import { OkmError } from "okmodel";
export function respond(error: unknown) {
return OkmError.from(error).toHttp();
}The unique error above maps to status 409 and body { code: "unique", reason: "Unique violation on notes.title (title)", fields: { title: "title" } }.
Retry
retryable marks whether a declared retry may run the call again. It does not start a retry by itself.
| Rule | Behaviour |
|---|---|
tx({ retry }) | Retries only when the error kind is serialization or deadlock. Every other kind is thrown as it is. |
| OKM1401 | Commit was sent and the result never arrived (outcome_unknown). Never retried. |
cancelled | Never retryable. The caller aborted the call. |
Other transient kinds | retryable is true by default (timeout, lock_timeout, unavailable, …). Your own loop may use that flag. tx({ retry }) still ignores them. |
tx({ retry })
Two serializable transactions that collide retry until one wins. Full options are Transactions.
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
const accounts = table("accounts", {
id: t.text().primaryKey(),
balance: t.integer(),
});
export const app = schema({ tables: [accounts] });
export async function retry(url: string): Promise<void> {
await using db = connect(url, { schema: app });
await db.accounts.insert([
{ id: "a", balance: 100 },
{ id: "b", balance: 100 },
]);
const attempts = { a: 0, b: 0 };
let waiting = 0;
let open = (): void => undefined;
const both = new Promise<void>((resolve) => {
open = resolve;
});
let releaseB = (): void => undefined;
const aCommitted = new Promise<void>((resolve) => {
releaseB = resolve;
});
const run = (me: "a" | "b") =>
db.tx({ isolation: "serializable", retry: 3 }, async (tx) => {
attempts[me] += 1;
const rows = await tx.accounts.find({ limit: 10 });
const total = rows.reduce((sum, row) => sum + row.balance, 0);
if (attempts[me] === 1) {
waiting += 1;
if (waiting === 2) open();
await both;
}
if (me === "b" && attempts.b === 1) await aCommitted;
await tx.accounts.update({ where: { id: me }, set: { balance: total } });
});
const a = run("a").finally(() => {
releaseB();
});
await Promise.all([a, run("b")]);
console.log(String(attempts.a + attempts.b));
const rows = await db.accounts.find({ orderBy: { id: "asc" }, limit: 2 });
console.log(JSON.stringify(rows.map((row) => ({ id: row.id, balance: row.balance }))));
}3
[{"id":"a","balance":200},{"id":"b","balance":300}]Custom loop on retryable
For kinds tx({ retry }) ignores, read retryable yourself:
import { OkmError } from "okmodel";
export async function withRetry<T>(run: () => Promise<T>, times = 3): Promise<T> {
let last: unknown;
for (let attempt = 1; attempt <= times; attempt += 1) {
try {
return await run();
} catch (error) {
last = error;
const err = OkmError.from(error);
console.log(`attempt ${String(attempt)}: retryable=${String(err.retryable)} kind=${err.kind}`);
if (!err.retryable || attempt === times) throw error;
}
}
throw last;
}attempt 1: retryable=true kind=serialization
attempt 2: retryable=true kind=serializationoutcome_unknown
OKM1401 means the commit was sent and the result never arrived. Do not run the write again blindly. Give the write an idempotency key (a unique column you control), then one or find for that key. If the row is there, the write landed; if it is not, run the write once more.
Write errors (OKM1120)
OKM1120 refuses an unknown field or option on a write. The message lists accepted names. On a tenant-scoped write those names omit the tenant key: the scope sets it, so it is not a field you pass. A close typo can still suggest the tenant key (Did you mean \tenantId`?), and the accepted-names list still leaves it out. Reads keep the tenant key in their accepted names. toHttp().body.fields` is empty for this guard.
Connect options
Set statuses, whether row values are kept, and the error logger once on connect.
| Field | Result |
|---|---|
errors.http | Statuses for OkmError.toHttp. toHttp() uses them when the caller passes no argument. toHttp(statuses) replaces them for that call. A category absent from the map keeps the default. |
errors.includeValues | true stores row values for values() and keeps bound parameters on the cause. Off unless set. A .sensitive() column stays redacted either way. Values are never logged. |
logger.error | Receives { code, summary } only. |
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
const notes = table("notes", {
id: t.identity(),
title: t.text(),
});
const app = schema({ tables: [notes] });
export const db = connect(process.env.DATABASE_URL!, {
schema: app,
errors: {
http: { conflict: 409 },
includeValues: true,
},
logger: {
error(entry) {
void entry.code;
void entry.summary;
},
},
});Never logged
The log record has no row values. logger.error receives { code, summary }. The toHttp body is { code, reason, fields }.