Operate

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

FieldWhat it is for
codeAn OKM1xxx guard, or the kind name when that kind has no number. Stable to branch on.
kindKind name (unique, serialization, outcome_unknown, …). Selects the category.
categoryOne of input, conflict, not_found, forbidden, transient, internal. Drives toHttp status.
retryableWhether 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.
issuesValidation 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.

read.ts
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.

branch.ts
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:

unique.out
{
  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.

CategoryDefault status
input422
conflict409
not_found404
forbidden403
transient503
internal500
http.ts
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.

RuleBehaviour
tx({ retry })Retries only when the error kind is serialization or deadlock. Every other kind is thrown as it is.
OKM1401Commit was sent and the result never arrived (outcome_unknown). Never retried.
cancelledNever retryable. The caller aborted the call.
Other transient kindsretryable 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.

retry-tx.ts
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 }))));
}
retry-tx.out
3
[{"id":"a","balance":200},{"id":"b","balance":300}]

Custom loop on retryable

For kinds tx({ retry }) ignores, read retryable yourself:

retry-loop.ts
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;
}
retry-loop.out
attempt 1: retryable=true kind=serialization
attempt 2: retryable=true kind=serialization

outcome_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.

FieldResult
errors.httpStatuses 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.includeValuestrue 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.errorReceives { code, summary } only.
db.ts
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 }.