Traits
trait() adds columns. The schema list is the default. A table adds its own, or opts out.
Where this fits
Schema traits apply to every table. This page is a trait you write, then the four that ship.
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.
import { t } from "okmodel/pg";
import { trait } from "okmodel/traits";
export const labeled = trait("labeled", {
fields: { label: t.text() },
});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.
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"],
});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.
import { t } from "okmodel/pg";
import { trait } from "okmodel/traits";
export const labeled = trait("labeled", {
fields: { label: t.text().default("x") },
sealed: ["label"],
});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.
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" }),
},
});OKM1061
methods and requires are refused: Trait option methods is not available yet. It arrives in 0.2.
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.
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() } });
const notes = table(
"notes",
{ id: t.identity(), title: t.text() },
{ traits: [labeled] },
);
const app = schema({ tables: [notes] });
export async function add(url: string, title: string, label: string) {
await using db = connect(url, { schema: app });
return db.notes.insert({ title, label });
}complex
fields, touch, sealed, and presets together. Insert omits the sealed columns. Update changes title, and labeledAt moves. marked() is the preset.
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"),
labeledAt: t.timestamptz().defaultSql("now()"),
},
touch: ["labeledAt"],
sealed: ["label"],
presets: {
marked: (q) => q.where({ label: "x" }),
},
});
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 });
await db.notes.update({ where: { id: note.id }, set: { title: "Next" } });
return db.notes.marked().find({ limit: 1 });
}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.
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.
import { table, t } from "okmodel/pg";
export const sessions = table(
"sessions",
{
id: t.identity(),
token: t.text(),
},
{ omitDefaults: "sessions are not stamped" },
);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.
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" })] },
);Insert omits both columns. The row comes back with them set. Update names the fields you change. updatedAt moves. createdAt does not.
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 });
return 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.
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"] })] },
);OKM1061
strategy: "table" is not available yet.
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.
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 } });
const still = await db.notes.withArchived().find({ where: { id }, limit: 1 });
await db.notes.restore({ archiveId: saved.archiveId });
return still;
}OKM1052
archive and restore on a table that is not archivable are refused.
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.
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()] },
);
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 db.tasks.update({
where: { id },
set: { title: "next" },
version: current.version,
});
}OKM1120
Leaving version out is refused, and so is version on a table that is not versioned. An upsert and .all() are refused.
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 }.
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")] },
);
const app = schema({ tables: [tasks] });
export async function place(url: string, id: string, otherId: string) {
await using db = connect(url, { schema: app });
return db.tasks.move(id, { before: otherId });
}OKM1120
Naming the group column in set is refused, even when the value is the one already stored.
OKM1060
via() on this table is refused. sortable() cannot group by position. An empty group name is refused.