Validation

A field's rules live in one place. They run when validation is enabled.

Where this fits

The column has a type. A rule here rejects a value that type still allows. It runs only when validation is on and the engine has loaded. References point a column at another table.

A field's rules live in one place. Insert and update run them when validation is on and okmodel/validate is loaded. A read does not. { validate: false } skips that call. A delete does not validate.

Inline

.validate() stores a list of built-in rules on the column, or one Standard Schema.

notes.ts
import { table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

export const notes = table("notes", {
  id: t.identity(),
  title: t.varchar(200).validate([v.trim(), v.min(1, "title_required")]), 
  email: t.text().validate([v.lowercase(), v.email("email_invalid")]), 
});

Section

validate is the section. Each key is a field, and $row is the whole row. Every value is a list of rules. A key that is not a column is OKM1060. A value that is not a list is OKM1060. A $row rule sees the row after the field transforms.

rows.ts
import { table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

export const notes = table(
  "notes",
  {
    id: t.identity(),
    title: t.text(),
    status: t.text().picklist(["draft", "active"]),
    qty: t.integer(),
  },
  {
    validate: { 
      title: [v.min(1, "title_required")], 
      $row: [ 
        v.rule( 
          (row: { readonly status?: string; readonly qty?: number }) =>
            row.status !== "active" || (row.qty ?? 0) > 0, 
          "qty", 
          "qty_required", 
        ), 
      ], 
    }, 
  },
);

OKM1030

The same field in .validate() and in this section is refused. okm check reports it, and so does the first validated call.

Built-in

v comes from okmodel/validate. Importing it loads the engine. trim and lowercase run before the checks. A string is changed, and that changed value is what the write stores. Any other value is left as it is. message on an issue is the key you pass, not display text.

OKM1201

A write that validates without okmodel/validate imported is refused. okm check reports it.

trim

v.trim() takes no argument. It removes leading and trailing whitespace from a string. The write stores that trimmed string.

trim.ts
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

const notes = table("notes", {
  id: t.identity(),
  title: t.text().validate([v.trim()]), 
});
const app = schema({ tables: [notes], validation: true });

export async function add(url: string) {
  await using db = connect(url, { schema: app });
  const row = await db.notes.insert.validate({ title: "  hello  " });
  return row.title;
}

lowercase

v.lowercase() takes no argument. It lowercases a string. A non-string is unchanged. The write stores the lowercased string.

lower.ts
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

const notes = table("notes", {
  id: t.identity(),
  email: t.text().validate([v.lowercase()]), 
});
const app = schema({ tables: [notes], validation: true });

export async function add(url: string) {
  await using db = connect(url, { schema: app });
  const row = await db.notes.insert.validate({ email: "A@B.C" });
  return row.email;
}

email

v.email(key) takes the issue key. A string must have one @, a dot after it, and no spaces. Any other value fails. The issue path is the field name.

email.ts
import { table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

export const people = table("people", {
  id: t.identity(),
  email: t.text().validate([v.email("email_invalid")]), 
});

min

v.min(n, key) takes the minimum and the issue key. A string is measured in code points and must be at least n. A number must be at least n. Any other value fails. The issue path is the field name.

min.ts
import { table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

export const notes = table("notes", {
  id: t.identity(),
  title: t.text().validate([v.min(1, "title_required")]), 
  qty: t.integer().validate([v.min(1, "qty_min")]), 
});

rule

v.rule(fn, path, key) takes the predicate, the issue path, and the issue key. false records key at path. That path is the segment you pass. It is not the field name plus the segment. On a field, fn receives the field value. On $row, fn receives the row after the field transforms.

reserved.ts
import { table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

export const notes = table("notes", {
  id: t.identity(),
  title: t.text().validate([
    v.rule((value: string) => value !== "admin", "title", "reserved"), 
  ]),
});

onInsert

v.onInsert() with no argument is a marker, not a check. Rules before it run on insert and update. Rules after it run only on insert, until the next onInsert() or onUpdate(). A transform after the marker changes the stored value only on insert. A Standard Schema in the list is not limited by the marker.

Pass one rule and only that rule is limited to insert. Rules after that call are not.

insert-phase.ts
import { table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

export const notes = table("notes", {
  id: t.identity(),
  title: t.text().validate([
    v.min(1, "title_required"),
    v.onInsert(), 
    v.min(2, "insert_min"), 
  ]),
});
insert-one.ts
import { table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

export const notes = table("notes", {
  id: t.identity(),
  title: t.text().validate([
    v.onInsert(v.min(2, "insert_min")), 
    v.min(1, "title_required"),
  ]),
});

onUpdate

v.onUpdate() with no argument is a marker. Rules after it run only on update, until the next marker. Pass one rule and only that rule is limited to update. Rules after that call are not.

update-phase.ts
import { table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

export const notes = table("notes", {
  id: t.identity(),
  title: t.text().validate([
    v.min(1, "title_required"),
    v.onUpdate(), 
    v.min(4, "update_min"), 
  ]),
});
update-min.ts
import { table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

export const notes = table("notes", {
  id: t.identity(),
  title: t.text().validate([v.onUpdate(v.min(4, "update_min"))]), 
});

Column checks

The column type adds a check when validation is on. You do not pass a rule for it. Guarded, generated, and identity columns are not checked.

columns.ts
import { table, t } from "okmodel/pg";

export const notes = table("notes", {
  id: t.identity(),
  title: t.varchar(8), 
  status: t.text().picklist(["draft", "active"]), 
  qty: t.integer(), 
  cost: t.numeric(8, 2), 
  score: t.double(), 
  token: t.uuid(), 
  meta: t.jsonb(), 
});
ColumnWhat failsIssue
required on insert, or nullnot nullable, and the column can be written. A default lets insert omit it. Passing null on a not-null column fails too, including one with a defaultrequired
t.varchar(n), t.char(n)longer than n code pointstoo_long
.picklist(values)a value that is not one of the strings. { check: false } skips the database CHECK and this check still runspicklist
t.smallint(), t.integer(), t.bigint()outside that type's range. A safe integer, a bigint, or a digit string is acceptedinteger_range
t.numeric(precision, scale)outside that precision and scale. A bare t.numeric() accepts a decimalprecision
t.real(), t.double()a value that is not a finite numberprecision
t.uuid()a string that is not 8-4-4-4-12 hexuuid
t.json(), t.jsonb()a value JSON cannot storejson

OKM1200

A failed check throws. The text is Validation failed: then the path and the key. issues lists { path, message } in column order, then $row. message is the key. A hidden or sensitive value is not written into the issue. A list prefixes each path with the row index.

Standard Schema

.validate() takes one Standard Schema instead of a list. ~standard.version is 1, and ~standard.validate checks the value. Zod, Valibot, and any other library with that shape work. A result of { value } replaces the field. Each issue message is kept, and a path on the issue is added after the field.

In the section the schema sits inside that field's list.

zod.ts
import { z } from "zod";
import { table, t } from "okmodel/pg";

export const notes = table("notes", {
  id: t.identity(),
  title: t.text().validate(z.string().trim().min(1)), 
});
valibot.ts
import * as v from "valibot";
import { table, t } from "okmodel/pg";

export const people = table("people", {
  id: t.identity(),
  email: t.text().validate(v.pipe(v.string(), v.email())), 
});

Flag

validation overrides the schema for this table. A value that is not a boolean or { enabled, onRead, style } is refused.

omitted

Leave it off the table. The table uses the schema flag, including onRead and style.

inherit.ts
import { schema, table, t } from "okmodel/pg";

const notes = table("notes", {
  id: t.identity(),
  title: t.text(),
});

export const app = schema({
  tables: [notes],
  validation: true, 
});

true

On for this table. onRead and style stay the schema's. true does not reset them.

switch.ts
import { table, t } from "okmodel/pg";

export const notes = table(
  "notes",
  {
    id: t.identity(),
    title: t.text(),
  },
  { validation: true }, 
);

false

Off for this table, even when the schema is on. onRead and style are still copied, and the rules do not run. A stored rule still makes a write demand okmodel/validate. That write is OKM1201 when the import is missing.

off.ts
import { table, t } from "okmodel/pg";

export const notes = table(
  "notes",
  {
    id: t.identity(),
    title: t.text(),
  },
  { validation: false }, 
);

enabled

Inside the object, omit enabled and the table is on. true is on. Any other value is off. onRead and style stay the schema's when you omit them.

enabled.ts
import { table, t } from "okmodel/pg";

export const notes = table(
  "notes",
  {
    id: t.identity(),
    title: t.text(),
  },
  { validation: { enabled: true } }, 
);

onRead

true is the only value that sets it. Omit it and the schema's value stays. The flag is stored. Insert and update run the rules. A read does not.

on-read.ts
import { table, t } from "okmodel/pg";

export const notes = table(
  "notes",
  {
    id: t.identity(),
    title: t.text(),
  },
  { validation: { onRead: true } }, 
);

style

"inline" is .validate(). "section" is the validate option. Anything else is refused: validation.style must be inline or section. Leave style unset and either place is allowed. The same field in both places is still OKM1030.

One style

A column rule under "section", or a section rule under "inline", is OKM1030.

section-style.ts
import { table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

export const notes = table(
  "notes",
  {
    id: t.identity(),
    title: t.text(),
  },
  {
    validation: { style: "section" }, 
    validate: { title: [v.min(1, "title_required")] },
  },
);

Run

Import okmodel/validate. A write that would validate without that import is OKM1201. okm check reports it too. These members exist on the table only when its validation is on.

insert.validate

insert.validate(row) checks the row and returns it frozen. A later insert of that value skips the rules. A list is checked the same way.

run.ts
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

const notes = table("notes", {
  id: t.identity(),
  title: t.varchar(200).validate([v.trim(), v.min(1, "title_required")]),
});
const app = schema({ tables: [notes], validation: true });

export async function add(url: string, title: string) {
  await using db = connect(url, { schema: app });
  const row = await db.notes.insert.validate({ title }); 
  return db.notes.insert(row);
}

insert.check

insert.check(row) runs the same checks and returns the issues. It does not throw OKM1200.

check.ts
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

const notes = table("notes", {
  id: t.identity(),
  title: t.varchar(200).validate([v.min(1, "title_required")]),
});
const app = schema({ tables: [notes], validation: true });

export async function issues(url: string, title: string) {
  await using db = connect(url, { schema: app });
  return db.notes.insert.check({ title }); 
}

pick

insert.pick(...fields) checks only the named fields. validate returns those fields frozen. check returns the issues.

pick.ts
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

const notes = table("notes", {
  id: t.identity(),
  title: t.varchar(200).validate([v.min(1, "title_required")]),
  body: t.text(),
});
const app = schema({ tables: [notes], validation: true });

export async function titleOnly(url: string, title: string) {
  await using db = connect(url, { schema: app });
  return db.notes.insert.pick("title").validate({ title }); 
}

omit

insert.omit(...fields) checks every field except those.

omit.ts
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

const notes = table("notes", {
  id: t.identity(),
  title: t.varchar(200).validate([v.min(1, "title_required")]),
  body: t.text(),
});
const app = schema({ tables: [notes], validation: true });

export async function withoutTitle(url: string, body: string) {
  await using db = connect(url, { schema: app });
  return db.notes.insert.omit("title").validate({ body }); 
}

update.validate

update.validate checks a patch. A body with set checks set. Update has no check, pick, or omit.

rename.ts
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

const notes = table("notes", {
  id: t.identity(),
  title: t.varchar(200).validate([v.min(1, "title_required")]),
});
const app = schema({ tables: [notes], validation: true });

export async function rename(url: string, title: string) {
  await using db = connect(url, { schema: app });
  return db.notes.update.validate({ set: { title } }); 
}

validate: false

{ validate: false } on insert or update skips the rules. Codecs still run. Delete ignores the option.

skip.ts
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { v } from "okmodel/validate";

const notes = table("notes", {
  id: t.identity(),
  title: t.varchar(200).validate([v.min(1, "title_required")]),
});
const app = schema({ tables: [notes], validation: true });

export async function raw(url: string, title: string) {
  await using db = connect(url, { schema: app });
  return db.notes.insert({ title }, { validate: false }); 
}