Entity
An entity says what one kind of data is: its fields, and the rules each field obeys. It is a
class extending entity(), and everything else — the type, the validation, the table, the
API, the form — is read from it.
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 the identity. Post names the table, the GraphQL type, the registration key
(post) and what a constructor asking for it receives; entity() takes no name of its own.
An entity with a single instance — settings, an address — is written the same way and listed in
a source's values:. A constructor then receives it as one value: see Values.
Field vocabulary
Fields that hold a value:
| Helper | Type | Options |
|---|---|---|
text(opts?) | string | min, max, pattern, format |
email(opts?) | string | the text options, minus format |
url(opts?) | string | the text options, minus format |
number(opts?) | number | min, max, integer |
bool(opts?) | boolean | — |
date(opts?) | Date | — |
oneOf(...values, opts?) | a union of literals | — |
list(item, opts?) | T[] | the item is any field |
Every helper takes a description: the sentence the field carries everywhere it appears — a
GraphQL field, a CLI flag, a form label. Helpers that shape a value also take a default.
Fields that play a part:
| Helper | Meaning |
|---|---|
primary() | the primary key, generated as a UUIDv7; it also wraps a field: primary(text()) |
ref(Entity) | a foreign key (string); ref(() => Entity) for a cycle |
many(Entity) | one-to-many — a role only, no column |
unique(f) | no two rows hold the same value — the database enforces it |
indexed(f) | reads often filter on it — adds an index, changes no answer |
primary() generates a UUIDv7: new rows
land at the end of the index, and Postgres stores it in its own 16-byte uuid type, as it does every
ref() pointing at it. A v7 carries the moment it was made — 01a19e42-175d-… reads as 2026-11-02
21:54:53. Where the id is public and that moment is not, name another generator:
id: primary({ generate: 'uuid' }) // v4: random, carries nothing
id: primary({ generate: 'cuid2' }) // random, about 90 µs an id where the others take under 1
A generator changes only the rows created after it: earlier ids stay as they are. On Postgres, a
table created when primary() still generated cuid2 keeps its key as text, and the boot refuses
it by name rather than reading every id as absent: convert the column, or write
primary({ generate: 'cuid2' }) to keep it.
Fields the server writes:
| Helper | Meaning |
|---|---|
created() | set when the row is created (createdAt), never by a client |
updated() | set again at every update (updatedAt) |
Wrappers, 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 | written at create, refused on update |
readOnly(f) | boundary | goes out, never comes in — the server writes it |
writeOnly(f) | boundary | comes in, never goes out — a password |
Wrappers nest: readOnly(optional(date())) is a date only the server writes, and that may be absent.
oneOf, min and max also become CHECK constraints in the table, so they hold even for a
write that skips the API — direct SQL, another process. pattern and format are checked by
the API only, because each database reads regular expressions differently.
A rule about several fields
"A book appears once in a list" is true of (listId, docId) and of neither field alone, so it
is declared on the entity rather than on a field:
class ListBook extends entity({
id: primary(),
listId: ref(List),
docId: text(),
}, {
unique: [['listId', 'docId']],
}) {}
The database enforces it, so two writes arriving together cannot both pass. A view that drops one
of the fields drops the rule with it: ListBook.pick('listId') no longer promises it. How a
reader in another language rebuilds such a rule is on the card.
Post.validate() sees one input, never the rows already stored, so it cannot check uniqueness;
the database refuses the duplicate. A primary() field is already unique.
References
ref(Entity) names a target, not a place. The target may belong to another frond, imported by
the name that frond publishes — never by a relative path, which 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 line is the same wherever the target's rows live. Who checks the reference depends on where they are:
| The target's rows | Who checks it | What it costs |
|---|---|---|
| the same source | the foreign key, references "users" ("id") | nothing |
| another source, in this process or another | Fougere asks for the target before the write | one read or one call per write, inside a transaction |
Which process serves the target's frond does not matter: sources: alone says where its rows are.
Across two sources, the source that names the target keeps a row for it in fougere_targets, created
by the migration. A write locks that row while it asks for the target and writes; a delete of the
target marks it gone while it asks every row that names it. The two wait for each other on that
row, so one of them is refused: a write never lands on a target that is being deleted. Writes naming
one target go one at a time, each with its call to the owner; on SQLite, every write to the file waits.
A source adapter writes nothing for this: any source with transactions gets it from update(id, change).
A source without them — memory, files, D1 — holds the lock in this process: a second process writing
the same rows does not see it.
A process with no address for the target's process has nobody to ask, and the write is not checked.
When the target is deleted
ref(Entity, { onDelete }) says what happens to the rows that name it:
restrict | the delete is refused while a row names it — the default |
cascade | the rows are deleted with it |
set null | the rows stay and the field is emptied — refused at boot if the field cannot be null |
The database carries it out where a foreign key holds the reference, Fougere does elsewhere.
Fougere deletes the deepest rows first and the target last, so an interruption leaves fewer
rows behind and never an orphan; finishing the job later is enough, nothing has to be undone.
If any reference in the tree is not held by a key, Fougere carries out the whole tree. Before a
row moves, every restrict of the tree is asked — here, across every process, and the ones a key
holds — so a refused delete leaves every row where it was. @fougere/workflow
records that a release started, so an interrupted one can be finished; a release another process is
driving is refused with LOCKED until it finishes or its lease runs out.
The four axes
Every field answers four separate questions, and every adapter reads its answer from them:
| Axis | Question | Read by |
|---|---|---|
| shape | which values are valid? (it is JSON Schema) | validation, forms, the column, the GraphQL scalar |
| role | what part does it play? (primary, ref, many, unique) | keys and constraints, relations, the card |
| lifecycle | who writes it, and when? | the storage: stamps, defaults, generated ids |
| boundary | which way does it cross the API? (readOnly, writeOnly) | what a client may send, what it may read |
The API checks what a client sends against shape and boundary: sending status, which
is readOnly, is refused. The storage then applies lifecycle: it sets createdAt and the
defaults. A key the entity does not declare is refused as Unknown field rather than dropped,
so an accepted input matches the contract exactly.
Entity.validate
The same validation runs in the browser and on the server (@cfworker/json-schema, which runs
anywhere JavaScript does):
Post.validate(input)
// → { success: true, data } valid
// → { success: false, errors: [{ path: ['title'], message }] } invalid, field by field
new Post(data) builds an instance without validating. Fields the server fills are optional
for the constructor and present on the resulting type.
Views
A view is a schema cut from an entity, for one use:
/** What an author may write. */
export class PostDraft extends Post.pick('slug', 'title', 'summary', 'body') {}
/** What the public index shows — no body. */
export class PostCard extends Post.pick('id', 'slug', 'title', 'summary', 'publishedAt') {}
A view is a full schema: it has getFields() and validate(), and it is a TypeScript type.
Views chain: Post.pick(…).partial().
| Derivation | Gives |
|---|---|
Post.pick('a', 'b') | only those fields |
Post.omit('a') | every field but those |
Post.rename({ a: 'b' }) | the same fields under other names, with their rules |
Post.partial() | every field optional — an absent field is not updated |
Post.extend({ extra: text() }) | the same fields, and new ones |
A view describes the rows of its entity, so it gets no table of its own. A view that drops
nothing describes exactly the same rows and would be ambiguous, so the boot refuses it until it
says which it is: .anchor() gives it rows of its own, which is how User extends AuthUser.
A view remembers where it came from — PostCard.derivation.source is Post, and
derivation.nameOf lists each field of Post with what the view kept of it. That is what lets
the card publish a view as a Post without body.
Where views are used:
- an operation's input — the parameter type; the API checks the body against it before your code runs (Handler);
- an operation's output —
Page<PostCard>says what leaves (Handler); - a form —
useFormFor(PostDraft)shows the view's fields (A form).
A field rule or a view?
Both readOnly/writeOnly and pick/omit keep a field off a surface. Choose by how far the
rule reaches:
| The rule is… | It lives… | Example |
|---|---|---|
| true everywhere | on the field — readOnly / writeOnly | a password never leaves |
| true for one use | in a view — pick / omit | this endpoint returns only id and title |
A field rule also travels on the card under x-fougere.boundary,
so a consumer in another language applies it too; a view is TypeScript only.
One more field
Add a field:
description: optional(text({ max: 500 })),
and run npm run migrate. The column is in the table; description is in every REST answer,
in the GraphQL type and its inputs; a form that loops over useFormFor(Post).fields shows it.
501 characters are refused in the browser, in REST and in GraphQL, by the same rule. You wrote
one line, and nothing else can fall behind it.