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 |
email(opts?) | string | text options minus format |
url(opts?) | string | text options minus format |
number(opts?) | number | min, max, integer |
bool(opts?) | boolean | — |
date(opts?) | Date | — |
oneOf(...values, opts?) | union of literals | — |
list(item, opts?) | T[] | item is any field |
Every word takes a description, the sentence the field carries onto every surface — a
GraphQL field, a CLI flag, a form label. The ones that shape a value take a default too,
which is a lifecycle rule: many() and primary() have none to hold and offer none.
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.
Where a reference lands
ref(Entity) names a target, not a place. The target may be an entity of another Frond,
reached through the name that Frond publishes — never a relative path, which is what
fougere check reports as cross-frond-import:
// fronds/blog/entities/Post.ts
import User from '@fronds/user/entities/User.js';
authorId: readOnly(ref(User)),
The declaration is the same wherever the target's rows turn out to live. What changes is who holds it, and the boot decides that per PAIR — never per engine:
| The target's rows | Who holds it | What it costs |
|---|---|---|
| the same source | the foreign key, references "users" ("id") | nothing |
| another source | the storage guard, which reads the row before the write | one read per write |
| another process | nobody, and the boot says so at startup | a row may name one that is gone |
A foreign key is a promise one database makes, so it stops at that database: two of them share no constraint. What does not stop is the promise itself, which is why the second row exists — the guard asks the target's storage whether the key is there, and refuses the write when it is not. It is not the same guarantee: the target can be deleted between the read and the write. It catches what a key catches in practice, which is a key that was never right — a row copied from another environment, a user deleted months ago, an id from a test.
The third row is the one nothing can hold, so it is announced instead:
WRN [relations] authorId → user — declared, and nothing in this process holds them:
no foreign key, and the target answers elsewhere. A row may name one that is gone.
What becomes of a row when the one it names goes
ref(Entity, { onDelete }) says it, and restrict is what an unstated one means — a foreign
key already refuses a delete that would orphan a row.
restrict | the delete is refused while a row names it |
cascade | the rows go with it |
set null | the rows stay, the field is emptied — refused at boot on a field admitting no null |
Who carries it out follows the same table as above: the engine where a key holds it, the framework otherwise. The framework walks the tree deepest first and takes the row out last, so an interruption leaves fewer children and never an orphan — which is what lets a release cross a process without a two-phase commit. There is nothing to undo, only something to finish.
One hop decides for the whole tree. A cascade an engine runs never passes through the framework, so a hop the engine owns above a hop it does not would take rows out with their own dependents left behind. One unkeyed hop anywhere below, and the framework takes all of it.
What finishes an interrupted release is @fougere/workflow: its
journal is one entity, and a run says somebody started. Leave it out and the hops are the same;
what stops simply stops.
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.