# Traits

A trait adds columns you would otherwise repeat on every table. On one table you write the trait, apply it, or use a built-in. On the schema that same list is copied onto every table that does not opt out.

## On one table

<Callout type="info" title="Where this fits">
[Schema traits](/docs/schema/traits#on-the-schema) apply to every table. This page covers writing a trait, the built-in traits, and using them.
</Callout>

### Create

`trait(name, definition)` builds a trait. Import it from `okmodel/traits`. The name is stored on each column it adds. `schema()` copies those columns onto the table. Schema traits apply first. This table's list applies after them.

#### fields

Required. Column builders, the same ones a table uses. An empty object is allowed. A missing object is OKM1060: `Trait labeled needs a fields object.` An empty name is OKM1060: `trait() trait needs a name.`

```ts title="fields.ts"
import { t } from "okmodel/pg";
import { trait } from "okmodel/traits";

export const labeled = trait("labeled", {
  fields: { label: t.text() }, // [!code focus]
});
```

#### touch

Field names set to `now()` on update. Each one is sealed. A name that is not a field of this trait is OKM1060: `Trait labeled touch names nope, which is not one of its fields. Accepted fields: label, labeledAt.` A value that is not a list is `Trait labeled touch must be a list of field names.`

```ts title="touch.ts"
import { t } from "okmodel/pg";
import { trait } from "okmodel/traits";

export const labeled = trait("labeled", {
  fields: {
    label: t.text(),
    labeledAt: t.timestamptz().defaultSql("now()"),
  },
  touch: ["labeledAt"], // [!code focus]
});
```

#### sealed

Field names input cannot set, including `{ allow }`. A touched field is sealed even when this list omits it. A name outside `fields` uses the same OKM1060 as `touch`.

```ts title="sealed.ts"
import { t } from "okmodel/pg";
import { trait } from "okmodel/traits";

export const labeled = trait("labeled", {
  fields: { label: t.text().default("x") },
  sealed: ["label"], // [!code focus]
});
```

#### presets

A named `where` on every table that uses the trait. The preset sees these columns, not the table's. A client method is OKM1040: `Preset find on trait labeled is a client method or a reserved name.` A name a table or another trait already uses is OKM1040: `Preset marked on notes is defined by table notes and by trait labeled.`

```ts title="presets.ts"
import { t } from "okmodel/pg";
import { trait } from "okmodel/traits";

export const labeled = trait("labeled", {
  fields: { label: t.text().default("x") },
  presets: {
    marked: (q) => q.where({ label: "x" }), // [!code focus]
  },
});
```

<Callout type="error" title="OKM1061">
`methods` and `requires` are refused: `Trait option methods is not available yet. It arrives in 0.2.`
</Callout>

### On a table

`traits` is a list. A value that is not a list is OKM1060: `Table notes traits must be a list of traits.`

#### simple

One field. Insert sets it.

```ts title="simple.ts"
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { trait } from "okmodel/traits";

const labeled = trait("labeled", { fields: { label: t.text() } }); // [!code focus]

const notes = table(
  "notes",
  { id: t.identity(), title: t.text() },
  { traits: [labeled] }, // [!code focus]
);
const app = schema({ tables: [notes] });

export async function add(url: string, title: string, label: string) {
  await using db = connect(url, { schema: app });
  return await db.notes.insert({ title, label }); // [!code focus]
}
```

#### complex

`fields`, `touch`, `sealed`, and `presets` together. Insert omits the sealed columns. Update changes `title`, and `labeledAt` moves. `marked()` is the preset.

```ts title="complex.ts"
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { trait } from "okmodel/traits";

const labeled = trait("labeled", {
  fields: {
    label: t.text().default("x"), // [!code focus]
    labeledAt: t.timestamptz().defaultSql("now()"), // [!code focus]
  },
  touch: ["labeledAt"], // [!code focus]
  sealed: ["label"], // [!code focus]
  presets: {
    marked: (q) => q.where({ label: "x" }), // [!code focus]
  },
});

const notes = table(
  "notes",
  { id: t.identity(), title: t.text() },
  { traits: [labeled] },
);
const app = schema({ tables: [notes] });

export async function rename(url: string, title: string) {
  await using db = connect(url, { schema: app });
  const note = await db.notes.insert({ title }); // [!code focus]
  await db.notes.update({ where: { id: note.id }, set: { title: "Next" } });
  return await db.notes.marked().find({ limit: 1 }); // [!code focus]
}
```

<Callout type="error" title="OKM1012">
A column the table already has is `Trait labeled adds notes.label, and notes already declares it.` A column another trait already added is `Trait labeled adds notes.label, and trait timestamps already adds it.`
</Callout>

### Omit defaults

`omitDefaults` is the reason. It drops the schema list and keeps this table's `traits`. An empty reason, or one that is only spaces, is OKM1060: `Table sessions omitDefaults needs a reason.`

```ts title="sessions.ts"
import { table, t } from "okmodel/pg";

export const sessions = table(
  "sessions",
  {
    id: t.identity(),
    token: t.text(),
  },
  { omitDefaults: "sessions are not stamped" }, // [!code focus]
);
```

### Built-in

`timestamps`, `archivable`, `versioned`, and `sortable` come from `okmodel/traits`.

| Trait | Columns | What you pass |
| --- | --- | --- |
| `timestamps()` | `createdAt`, `updatedAt` | `{ enforce: "trigger" }` for a database trigger. |
| `archivable()` | `archivedAt`, `archiveId` | `cascade`, `strategy`. |
| `versioned()` | `version` | Nothing. |
| `sortable(groupBy?)` | `position` | A column name, or nothing for one list. |

#### Timestamps

Both columns are `timestamptz`, not null, default `now()`, guarded and sealed. Insert leaves them to the default. Update sets `updatedAt` and leaves `createdAt`. `{ enforce: "trigger" }` also writes `updatedAt` in the database. Any other option is refused.

```ts title="timestamps.ts"
import { table, t } from "okmodel/pg";
import { timestamps } from "okmodel/traits";

export const notes = table(
  "notes",
  {
    id: t.identity(),
    title: t.text(),
  },
  { traits: [timestamps({ enforce: "trigger" })] }, // [!code focus]
);
```

Insert omits both columns. The row comes back with them set. Update names the fields you change. `updatedAt` moves. `createdAt` does not.

```ts title="stamp.ts"
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { timestamps } from "okmodel/traits";

const notes = table(
  "notes",
  { id: t.identity(), title: t.text() },
  { traits: [timestamps()] },
);
const app = schema({ tables: [notes] });

export async function rename(url: string, title: string) {
  await using db = connect(url, { schema: app });
  const note = await db.notes.insert({ title }); // [!code focus]
  return await db.notes.update({ where: { id: note.id }, set: { title: "Next" } });
}
```

#### Archive

`archivedAt` is `timestamptz` and nullable. `archiveId` is `uuid` and nullable. Both are guarded and sealed. Uniques on that table become partial. `cascade` names child tables archived and restored with the row. An empty name is refused. `strategy: "column"` keeps archived rows in the table.

```ts title="archive.ts"
import { table, t } from "okmodel/pg";
import { archivable } from "okmodel/traits";

export const notes = table(
  "notes",
  {
    id: t.identity(),
    title: t.text(),
  },
  { traits: [archivable({ cascade: ["comments"] })] }, // [!code focus]
);
```

<Callout type="error" title="OKM1061">
`archivable({ strategy: "table" }) is not available yet. It arrives once archived snapshots can follow migrations.`
</Callout>

`archive({ where })` sets `archivedAt` and one new `archiveId`, shared with the cascaded children. It returns `{ count, archiveId }`. A later `find` does not see that row. `withArchived()` includes it. `onlyArchived()` is the archived set only. `restore({ archiveId })` restores that whole operation. `restore({ where })` restores those rows and the children that carry their `archiveId`.

```ts title="retire.ts"
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { archivable } from "okmodel/traits";

const notes = table(
  "notes",
  { id: t.identity(), title: t.text() },
  { traits: [archivable()] },
);
const app = schema({ tables: [notes] });

export async function retire(url: string, id: string) {
  await using db = connect(url, { schema: app });
  const saved = await db.notes.archive({ where: { id } }); // [!code focus]
  const still = await db.notes.withArchived().find({ where: { id }, limit: 1 });
  await db.notes.restore({ archiveId: saved.archiveId });
  return still;
}
```

<Callout type="error" title="OKM1052">
`archive` and `restore` on a table that is not archivable are refused.
</Callout>

#### Versioned

`version` starts at 1. A stale write is a conflict and changes nothing. A miss stays `{ count: 0 }`. `inc` and the other atomic writes still bump `version`. `delete`, `archive`, and `restore` do not check it. Read the row, then pass `version` back on the update. The write bumps it. The check is this trait: raw SQL that updates the row does not check or bump `version`. `conflict` does not say whether the row is stale or deleted.

```ts title="versioned.ts"
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { versioned } from "okmodel/traits";

const tasks = table(
  "tasks",
  { id: t.text().primaryKey(), title: t.text() },
  { traits: [versioned()] }, // [!code focus]
);
const app = schema({ tables: [tasks] });

export async function edit(url: string, id: string) {
  await using db = connect(url, { schema: app });
  const current = await db.tasks.one({ where: { id } });
  if (current === null) return null;
  return await db.tasks.update({
    where: { id },
    set: { title: "next" },
    version: current.version, // [!code focus]
  });
}
```

<Callout type="error" title="OKM1120">
Leaving `version` out is a TypeScript error on a versioned table, and OKM1120 at runtime when types are bypassed. `version: "any"` is allowed only with `lock: "skip"` and `limit`. An unrecognised version string, and a `version` key in `set`, are OKM1120. `version` on a table that is not versioned is refused. An upsert and `.all()` are refused.
</Callout>

`version: "any"` skips the check and still stores `version + 1`. The call returns the claimed rows, including that new version. Nothing claimed is `[]`, not a conflict. The statement locks the primary key once and repeats your `where` on the update, so a row whose state changed is left alone and the claim never takes more than `limit` rows. Run it in `READ COMMITTED`. `REPEATABLE READ` and `SERIALIZABLE` can raise SQLSTATE 40001 instead of dropping that row.

A queue claims, then fences the worker's own later writes on the version from that worker's last write. Heartbeat, complete, and fail pass that integer plus the id, the running status, and the attempt. Fail uses the same fence as complete and sets a failed status. Rescue is another `version: "any"` claim. A job that dies before its first heartbeat has `heartbeatAt` null, so rescue matches that only when `claimedAt` is already stale, and it writes a new `heartbeatAt` so a second rescuer does not claim the same row. Handlers run at least once, so they have to be idempotent. A hot queue that heartbeats on a short interval fits a table that is not `versioned()`.

```ts title="queue.ts"
import { connect } from "okmodel/pg/postgresjs";
import { inc, lt, or, schema, table, t } from "okmodel/pg";
import { versioned } from "okmodel/traits";

const jobs = table(
  "jobs",
  {
    id: t.text().primaryKey(),
    status: t.text(),
    attempt: t.integer().default(0),
    owner: t.text().nullable(),
    claimedAt: t.timestamptz().nullable(),
    heartbeatAt: t.timestamptz().nullable(),
  },
  { traits: [versioned()] },
);
const app = schema({ tables: [jobs] });

export async function work(url: string, owner: string) {
  await using db = connect(url, { schema: app });
  const now = Temporal.Now.instant();
  const [job] = await db.jobs.update({
    where: { status: "pending" },
    set: { status: "running", attempt: inc(1), claimedAt: now, owner },
    limit: 1,
    lock: "skip",
    version: "any",
  });
  if (job === undefined) return;
  const [beat] = await db.jobs.update(
    {
      where: { id: job.id, status: "running", attempt: job.attempt },
      set: { heartbeatAt: now },
      version: job.version,
    },
    { returning: true },
  );
  if (beat === undefined) return;
  await db.jobs.update({
    where: { id: beat.id, status: "running", attempt: beat.attempt },
    set: { status: "done" },
    version: beat.version,
  });
}

export async function rescue(url: string, owner: string) {
  await using db = connect(url, { schema: app });
  const now = Temporal.Now.instant();
  const stale = now.subtract({ minutes: 5 });
  await db.jobs.update({
    where: or([
      { status: "running", heartbeatAt: null, claimedAt: lt(stale) },
      { status: "running", heartbeatAt: lt(stale) },
    ]),
    set: { status: "running", attempt: inc(1), claimedAt: now, heartbeatAt: now, owner },
    limit: 1,
    lock: "skip",
    version: "any",
  });
}
```

#### Sortable

New rows go at the end of the group. `find` and `page` order by `position` when you omit `orderBy`. `one()` does not. A move rewrites every row in the group. A delete leaves a gap until the next `move`. `move(id, { before })` or `{ after }` places that row next to the anchor in the same group. The result is `{ position }`.

```ts title="sortable.ts"
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { sortable } from "okmodel/traits";

const tasks = table(
  "tasks",
  { id: t.text().primaryKey(), title: t.text(), laneId: t.text() },
  { traits: [sortable("laneId")] }, // [!code focus]
);
const app = schema({ tables: [tasks] });

export async function place(url: string, id: string, otherId: string) {
  await using db = connect(url, { schema: app });
  return await db.tasks.move(id, { before: otherId }); // [!code focus]
}
```

<Callout type="error" title="OKM1120">
Naming the group column in `set` is refused, even when the value is the one already stored.
</Callout>

<Callout type="error" title="OKM1060">
`via()` on this table is refused. `sortable()` cannot group by `position`. An empty group name is refused.
</Callout>

## On the schema

`traits` is a list. Schema traits apply first. A table's own list applies after them. An omitted list and `[]` add nothing. Import `okmodel/traits` to load the columns. A schema that never imports a trait does not carry that code.

`timestamps()`, `archivable()`, `versioned()`, and `sortable()` are the built-ins. A trait you write uses the same list. The builders are on [traits](/docs/schema/traits#on-one-table). `archivable()` and `sortable()` rewrite uniques after tenancy has widened them.

### omitted

No list, or `[]`, leaves each table's own columns.

```ts title="omitted.ts"
import { schema, table, t } from "okmodel/pg";

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

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

### list

Each entry is a trait. `timestamps()` adds `createdAt` and `updatedAt`. Both are `timestamptz not null default now()`, guarded, and sealed. Insert leaves them to the default. Update sets `updatedAt` and leaves `createdAt`.

```ts title="list.ts"
import { schema, table, t } from "okmodel/pg";
import { timestamps } from "okmodel/traits";

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

export default schema({
  tables: [notes],
  traits: [timestamps()], // [!code focus]
});
```

#### trigger

`{ enforce: "trigger" }` also writes `updatedAt` in the database. The catalog gains `okm_touch_<column>` and one before-update trigger per table, named `<table>_touch`. `<column>` and `<table>` are the SQL names. With [casing](/docs/schema/names-and-casing#casing) `"snake"`, the function is `okm_touch_updated_at`.

```ts title="trigger.ts"
import { schema, table, t } from "okmodel/pg";
import { timestamps } from "okmodel/traits";

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

export default schema({
  tables: [notes],
  traits: [timestamps({ enforce: "trigger" })], // [!code focus]
});
```

<Callout type="error" title="OKM1060">
Another key is `timestamps() option extra is not supported. Accepted options: enforce.` Another value is `timestamps() enforce database must be trigger.`
</Callout>

### opt out

`omitDefaults` is a reason. It drops the schema list and keeps traits declared on that table. An empty reason, or one that is only spaces, is refused.

```ts title="opt-out.ts"
import { schema, table, t } from "okmodel/pg";
import { timestamps } from "okmodel/traits";

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

const sessions = table(
  "sessions",
  { id: t.identity(), token: t.text() },
  { omitDefaults: "sessions are not stamped" }, // [!code focus]
);

export default schema({
  tables: [notes, sessions],
  traits: [timestamps()],
});
```

<Callout type="error" title="OKM1060">
A value that is not a list is `schema() traits must be a list of traits.` The same on a table is `Table notes traits must be a list of traits.` An empty reason is `Table sessions omitDefaults needs a reason.`
</Callout>

### together

Schema traits apply to `notes`. `tasks` adds `sortable` after them, so it also has `position`. `sessions` opts out, so it has neither `createdAt` nor `label`.

```ts title="together.ts"
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { sortable, timestamps, trait } from "okmodel/traits";

const labeled = trait("labeled", {
  fields: { label: t.text().default("note") },
});

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

const tasks = table(
  "tasks",
  { id: t.identity(), title: t.text(), laneId: t.text() },
  { traits: [sortable("laneId")] }, // [!code focus]
);

const sessions = table(
  "sessions",
  { id: t.identity(), token: t.text() },
  { omitDefaults: "sessions are not stamped" },
);

const app = schema({
  tables: [notes, tasks, sessions],
  traits: [timestamps(), labeled], // [!code focus]
});

export async function open(url: string, title: string, token: string) {
  await using db = connect(url, { schema: app });
  const note = await db.notes.insert({ title });
  const session = await db.sessions.insert({ token });
  return { createdAt: note.createdAt, token: session.token };
}
```

<Callout type="error" title="OKM1012">
A column the table already has is `Trait labeled adds notes.label, and notes already declares it.` A column another trait already added is `Trait labeled adds notes.label, and trait timestamps already adds it.`
</Callout>

### Create your own

`timestamps()`, `archivable()`, `versioned()`, and `sortable()` are the built-ins above. A trait you write is `trait(name, { fields })` from `okmodel/traits`, the same function those built-ins call. Put it on this list and it is copied onto every table that does not opt out. The field options are on [traits](/docs/schema/traits#on-one-table).

#### Simple trait

One text column with a default. Insert omits it, and the insert is awaited: `await using` closes the client when the function returns, so a returned promise would close it before the row is written. `okm generate` adds `"label" text not null default 'note'` on `notes`.

```ts title="own.ts"
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { trait } from "okmodel/traits";

const labeled = trait("labeled", {
  fields: { label: t.text().default("note") }, // [!code focus]
});

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

export const app = schema({
  tables: [notes],
  traits: [labeled], // [!code focus]
});

export async function add(url: string, title: string) {
  await using db = connect(url, { schema: app });
  return await db.notes.insert({ title }); // [!code focus]
}
```

#### Advanced

`stamped` seals `label`, so input cannot set it, and touches `labeledAt`, so an update sets that column to `now()`. `timestamps()` is the same list, applied first, so the table also has `createdAt` and `updatedAt`.

```ts title="own-advanced.ts"
import { schema, table, t } from "okmodel/pg";
import { timestamps, trait } from "okmodel/traits";

const stamped = trait("stamped", {
  fields: {
    label: t.text().default("note"), // [!code focus]
    labeledAt: t.timestamptz().defaultSql("now()"), // [!code focus]
  },
  touch: ["labeledAt"], // [!code focus]
  sealed: ["label"], // [!code focus]
});

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

export default schema({
  tables: [notes],
  traits: [timestamps(), stamped], // [!code focus]
});
```

<Callout type="error" title="OKM1012">
A column the table already declares is `Trait stamped adds notes.label, and notes already declares it.`
</Callout>

| Next | You use it for |
| --- | --- |
| [Tenancy](/docs/schema/tenancy#on-a-table) | The tenant key on one table or on the schema. |

Index: [/llms.txt](/llms.txt)
