Schema

Validation

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

Validation rejects a value the column type still allows. On a table the rules sit on the field or in a validate section. On the schema a flag turns those rules on, off, or limits where they may live.

On a table

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. An unknown write key is OKM1120 and names the key and the table. A read does not. { validate: false } skips that call. A delete does not validate. The same insert and update handles work inside batch(): rules run before the plan executes, and a failing rule rolls the whole batch back with OKM1200, issues, and batchIndex.

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 await db.notes.insert(row);
}

insert.check

insert.check(row) runs the same checks. A failed rule returns the issues and does not throw OKM1200. An unknown key throws OKM1120 (Field csrf is not on posts. Accepted names: displayName, id.) before those issues are returned, so the rule issues are lost. The same refusal is on insert.validate, insert.pick(...).validate, insert.pick(...).check, insert.omit(...).validate, insert.omit(...).check, update.validate, and insert["~standard"].validate. The Standard Schema validate throws that OKM1120. It does not return { issues } for an unknown key. A failed rule on that surface still returns { issues }.

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 await db.notes.insert.check({ title }); 
}

pick

insert.pick(...fields) checks only the named fields. validate returns those fields frozen. check returns the issues for a failed rule. An unknown key throws OKM1120 on both, and check does not return the rule 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 await db.notes.insert.pick("title").validate({ title }); 
}

omit

insert.omit(...fields) checks every field except those. validate and check throw OKM1120 on an unknown key. check returns rule issues only when every key is a field.

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 await db.notes.insert.omit("title").validate({ body }); 
}

update.validate

update.validate checks a patch. A body with set checks set. An unknown key in the patch, or in set, throws OKM1120. 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 await 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 await db.notes.insert({ title }, { validate: false }); 
}

On the schema

validation is stored as you pass it. schema() does not read the rules. okm check does, and the engine does on the first validated call. Import okmodel/validate before that call, or a write that would validate is refused. The rules themselves are on the table.

Omitted and false are off. true is on. An object is on unless enabled is false. Omitting enabled inside an object leaves it on. Insert and update run the rules, including when those writes sit in batch(). An unknown write key is OKM1120 and names the key and the table, the same as when validation is off. A read does not run the rules. { validate: false } skips that call. onRead is stored. The write path does not read it.

style chooses where a rule may live. "inline" is .validate() on the column. "section" is the table validate section. Omitted, either place is accepted, and the same field in both is refused.

off

Omitted, rules stay off. false is the same.

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

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

export default schema({
  tables: [notes],
});

false leaves them off as well.

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

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

export default schema({
  tables: [notes],
  validation: false, 
});

on

true turns rules on. onRead stays false. style stays unset, so a column rule and a validate section may both exist, on different fields.

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

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

export default schema({
  tables: [notes],
  validation: true, 
});

object

enabled, onRead, and style are the keys. onRead: true is stored with the flag. A read still does not run the rules.

Inline style

"inline" keeps column rules and refuses a validate section, including $row.

inline.ts
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(), v.min(1, "title_required")]),
});

export default schema({
  tables: [notes],
  validation: { enabled: true, onRead: false, style: "inline" }, 
});

Section style

"section" keeps the table validate section and refuses .validate() on a column.

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

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

export default schema({
  tables: [notes],
  validation: { style: "section" }, 
});

OKM1060

Anything other than a boolean or an object is schema() validation must be a boolean or { enabled, onRead, style }. Another style is validation.style must be inline or section. The same on a table is table() validation must be a boolean or { enabled, onRead, style }.

OKM1030

The same field in both places is Field notes.title is validated in two places. Keep the column rules or the validate section. "inline" with a validate section, including $row, is the same code. "section" with .validate() is the same code.

table

A table flag replaces enabled and keeps the schema's onRead and style. true turns that table on. false turns it off. An object can set its own onRead and style. Omitting either one keeps the schema value.

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

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

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

export default schema({
  tables: [notes, drafts],
  validation: { enabled: true, style: "inline" },
});

together

insert.validate checks the row and returns it frozen. A later insert of that value skips the rules. insert.check returns the issues for a failed rule and does not throw OKM1200 for that failure. An unknown key throws OKM1120 on insert.validate, insert.check, insert.pick(...).validate, insert.pick(...).check, insert.omit(...).validate, insert.omit(...).check, update.validate, and insert["~standard"].validate, and the rule issues are not returned. The Standard Schema validate throws that OKM1120. It does not return { issues } for an unknown key.

together.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 await db.notes.insert(row);
}

OKM1201

A write that would validate, when okmodel/validate was not imported, is Validation is enabled but `okmodel/validate` was not imported.

OKM1200

A failed validate is Validation failed: title title_required. The message names the first field and its key. check returns those rule issues. An unknown key on check throws OKM1120, and the rule issues are lost.

NextYou use it for
VisibilityWhich fields a caller can read or write.