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:
| Helper | Type | Options |
|---|---|---|
text(opts?) | string | min, max, pattern, format, default |
email(opts?) | string | text options minus format |
url(opts?) | string | text options minus format |
number(opts?) | number | min, max, integer, default |
bool(opts?) | boolean | default |
date() | Date | — |
oneOf(...values, opts?) | union of literals | default |
list(item, opts?) | T[] | item is any field |
Role fields:
| Helper | Meaning |
|---|---|
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:
| Helper | Meaning |
|---|---|
created() | stamped at create (createdAt) — never client-written |
updated() | re-stamped at every update (updatedAt) |
Wrappers (compose around any field):
| Wrapper | Axis | Effect |
|---|---|---|
optional(f) | shape | may be absent; T | null |
nullable(f) | shape | may be null, must be present |
immutable(f) | lifecycle | writable at create, forbidden on update |
readOnly(f) | boundary | never crosses inward — output only |
writeOnly(f) | boundary | never 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. Aprimary()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:
| Axis | Question | Read by |
|---|---|---|
| shape | what values are valid? (the shape is JSON Schema) | validation, forms, SQL column, GraphQL scalar |
| role | what part does it play? (primary, ref, many, unique) | DDL (PK/FK, constraints), relations, the card |
| lifecycle | who writes it, when? (auto, updated, immutable) | ORM realization, constructor relaxation |
| boundary | which 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 fieldinstead 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.