Entities

An entity is a class extending the entity() factory. It provides the TypeScript type, validation, and the metadata read by adapters.

import { entity, primary, text, ref, created, oneOf, date, readOnly, optional } from '@fougere/schema';
import Author from './Author.js';

export default class Post extends entity({
  id: primary(),
  slug: text({ min: 1, max: 80 }),
  title: text({ min: 1, max: 160 }),
  body: optional(text()),
  authorId: ref(Author),
  createdAt: created(),
  status: readOnly(oneOf('draft', 'published', { default: 'draft' })),
  publishedAt: readOnly(optional(date())),
}) {}

The class name is used as the identity: Post.name names the SQLite table, the GraphQL type, the registration key (post), and the DI match. entity() takes no separate name.

Field vocabulary

Value fields:

HelperTypeOptions
text(opts?)stringmin, max, pattern, format, default
email(opts?)stringtext options minus format
url(opts?)stringtext options minus format
number(opts?)numbermin, max, integer, default
bool(opts?)booleandefault
date()Date
oneOf(...values, opts?)union of literalsdefault
list(item, opts?)T[]item is any field

Role fields:

HelperMeaning
primary()primary key, generated — also wraps a field: primary(text())
ref(Entity)foreign key (string); accepts () => Entity for cycles
many(Entity)one-to-many — role only, no column
unique(f)no two rows carry the same value — a constraint the database enforces
indexed(f)reads filter on this often — emits CREATE INDEX, changes no answer

The oneOf, min, and max rules are also emitted as CHECK constraints. They therefore apply to writes that bypass the façade, such as direct SQL or another process. pattern and format remain façade validations because regular-expression dialects differ across databases.

Some facts are about a pair, not a field. "A book appears once in a list" is true of (listId, docId) and of neither alone, so it is declared on the entity:

class ListBook extends entity({
  id: primary(),
  listId: ref(List),
  docId: text(),
}, {
  unique: [['listId', 'docId']],
}) {}

The database enforces this constraint so that concurrent writes are covered as well. A derivation that removes one group member also removes the group: keeping only (listId) would change the declared rule.

Lifecycle fields:

HelperMeaning
created()stamped at create (createdAt) — never client-written
updated()re-stamped at every update (updatedAt)

Wrappers (compose around any field):

WrapperAxisEffect
optional(f)shapemay be absent; T | null
nullable(f)shapemay be null, must be present
immutable(f)lifecyclewritable at create, forbidden on update
readOnly(f)boundarynever crosses inward — output only
writeOnly(f)boundarynever crosses outward — input only (passwords)

Wrappers nest: readOnly(optional(date())) is a server-owned, possibly-absent date.

Post.validate() only sees the current input and therefore cannot check uniqueness against existing rows. The database enforces that rule and returns a driver error rather than a field error. A primary() field is already unique.

Two ways to write it, two owners

unique(slug) declares a constraint on one field; unique: [['listId','docId']] declares one across several. They are not the same statement, so they do not live in the same place: a constraint on one field is that field's, and a constraint spanning several is the schema's — it belongs to none of them alone.

At runtime a field carries role.unique, a boolean, read through Role.of(field).isUnique. A group spanning several fields is held once by the schema and answered by getUnique().

The card flattens both onto the members, because a reader in another language sees one field at a time and must be able to rebuild the constraint from what that field carries:

// describe(ListBook) — the `role.unique` of each member
"listId": { "role": { "unique": [["listId", "docId"]] } },
"docId":  { "role": { "unique": [["listId", "docId"]] } }

A field belonging to two constraints carries two entries. Each member carries the whole group, which is what allows a compound constraint to be rebuilt from the card alone.

A derivation that removes a member removes the group: Post.pick('listId') does not retain the compound constraint.

The four axes

Every field carries four orthogonal axes — this is the model all adapters read:

AxisQuestionRead by
shapewhat values are valid? (the shape is JSON Schema)validation, forms, SQL column, GraphQL scalar
rolewhat part does it play? (primary, ref, many, unique)DDL (PK/FK, constraints), relations, the card
lifecyclewho writes it, when? (create: 'now' | 'optional' | {value} | {generate}, update: 'now' | 'forbidden')storage realization, constructor relaxation
boundarywhich way does it cross the API? (readOnly, writeOnly)Visibility.input / Visibility.output

The façade validates client input against the shape and boundary axes: writing a readOnly field such as status is rejected. The storage then applies the lifecycle axis, for example by setting createdAt and default values.

Validation rejects unknown keys with Unknown field instead of silently dropping them. An accepted input therefore matches the contract exactly (see Handlers).

Entity.validate

The validation engine is edge-safe (@cfworker/json-schema). The same function can run in the browser and in the handler:

Post.validate(input)
// → { success: true, data }                                    valid
// → { success: false, errors: [{ path: 'title', message }] }   invalid, per-field

new Post(data) builds an instance without validation. Generated and automatic fields are optional for the constructor but remain present on the resulting type.

Next: Views — deriving contracts from the schema.

Built with Fougere — this site runs on the framework it documents.