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, 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:
| 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? (create: 'now' | 'optional' | {value} | {generate}, update: 'now' | 'forbidden') | storage realization, constructor relaxation |
| boundary | which 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 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.