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, one shape

unique(slug) declares a constraint on one field; unique: [['listId','docId']] declares one across several fields. Both use the same internal representation: a list of field groups.

Each field's role axis contains the constraints it belongs to:

slug.role.unique   // [['slug']]                — the set of one
listId.role.unique // [['listId', 'docId']]     — the pair, held by each member
docId.role.unique  // [['listId', 'docId']]

A field belonging to two constraints carries two entries. Each member contains the whole group, allowing a consumer in another language to rebuild a compound constraint.

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? (auto, updated, immutable)ORM realization, constructor relaxation
boundarywhich way does it cross the API? (readOnly, writeOnly)io projections inputFields / outputFields

The façade validates client input against the shape and boundary axes: writing a readOnly field such as status is rejected. The ORM 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.