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:

HelperTypeOptions
text(opts?)stringmin, max, pattern, format
email(opts?)stringthe text options, minus format
url(opts?)stringthe text options, minus format
number(opts?)numbermin, 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:

HelperMeaning
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:

HelperMeaning
created()set when the row is created (createdAt), never by a client
updated()set again at every update (updatedAt)

Wrappers, around any field:

WrapperAxisEffect
optional(f)shapemay be absent; T | null
nullable(f)shapemay be null, must be present
immutable(f)lifecyclewritten at create, refused on update
readOnly(f)boundarygoes out, never comes in — the server writes it
writeOnly(f)boundarycomes 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 rowsWho checks itWhat it costs
the same sourcethe foreign key, references "users" ("id")nothing
another source, in this process or anotherFougere asks for the target before the writeone 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:

restrictthe delete is refused while a row names it — the default
cascadethe rows are deleted with it
set nullthe 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:

AxisQuestionRead by
shapewhich values are valid? (it is JSON Schema)validation, forms, the column, the GraphQL scalar
rolewhat part does it play? (primary, ref, many, unique)keys and constraints, relations, the card
lifecyclewho writes it, and when?the storage: stamps, defaults, generated ids
boundarywhich 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().

DerivationGives
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 everywhereon the field — readOnly / writeOnlya password never leaves
true for one usein a view — pick / omitthis 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.

Put it to work

Built with Fougere and Nuxt, hosted on GitHub Pages.