Traits
trait() adds columns. The schema list is the default. A table adds its own, or opts out.
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
Where this fits
Schema traits apply to every table. This page covers writing a trait, the built-in traits, and using them.
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 await 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 await 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 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.
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
archivable({ strategy: "table" }) is not available yet. It arrives once archived snapshots can follow migrations.
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. 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.
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 await db.tasks.update({
where: { id },
set: { title: "next" },
version: current.version,
});
}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.
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().
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 }.
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 await 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.
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. archivable() and sortable() rewrite uniques after tenancy has widened them.
omitted
No list, or [], leaves each table's own columns.
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.
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()],
});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 "snake", the function is okm_touch_updated_at.
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" })],
});OKM1060
Another key is timestamps() option extra is not supported. Accepted options: enforce. Another value is timestamps() enforce database must be trigger.
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.
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" },
);
export default schema({
tables: [notes, sessions],
traits: [timestamps()],
});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.
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.
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")] },
);
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],
});
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 };
}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.
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.
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.
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") },
});
const notes = table("notes", {
id: t.identity(),
title: t.text(),
});
export const app = schema({
tables: [notes],
traits: [labeled],
});
export async function add(url: string, title: string) {
await using db = connect(url, { schema: app });
return await db.notes.insert({ title });
}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.
import { schema, table, t } from "okmodel/pg";
import { timestamps, trait } from "okmodel/traits";
const stamped = trait("stamped", {
fields: {
label: t.text().default("note"),
labeledAt: t.timestamptz().defaultSql("now()"),
},
touch: ["labeledAt"],
sealed: ["label"],
});
const notes = table("notes", {
id: t.identity(),
title: t.text(),
});
export default schema({
tables: [notes],
traits: [timestamps(), stamped],
});OKM1012
A column the table already declares is Trait stamped adds notes.label, and notes already declares it.
| Next | You use it for |
|---|---|
| Tenancy | The tenant key on one table or on the schema. |