The card and the wire
A class cannot travel between processes. Its card can: a JSON document that describes the
entity, from which another process — or another language — rebuilds the same rules.
~standard hands the live validator to a library in the same
process; the card is what you send when the reader is somewhere else.
import { Bundle, Card } from '@fougere/schema';
const descriptor = Card.fromSchema(Post).descriptor; // schema to JSON document
const Rebuilt = Card.fromDescriptor(descriptor).toSchema(); // document to living schema
The rebuilt schema validates locally. Give it the type of a row and it is a class like any entity
— one name for the validator and the type. That is what
fougere sync writes:
class Post extends Card.fromDescriptor<{ id: string; title: string }>(descriptor).toSchema() {}
The structure
A card is a valid JSON Schema document. A field's shape is written in JSON Schema's own keywords;
its three other axes sit under the x-fougere key.
{
"title": "Post",
"type": "object",
"properties": {
"id": {
"type": "string",
"x-fougere": {
"role": { "primary": true },
"lifecycle": { "create": { "generate": "uuidv7" }, "update": "forbidden" }
}
},
"title": { "type": "string", "minLength": 3, "maxLength": 120 },
"status": { "type": "string", "enum": ["draft", "published"] },
"publishedAt": {
"type": ["string", "null"],
"format": "date-time",
"x-fougere": { "lifecycle": { "update": "forbidden" } }
},
"secret": { "type": "string", "x-fougere": { "boundary": { "out": "closed" } } }
},
"required": ["title", "status"],
"x-fougere-version": 1,
"x-fougere-vendor": "fougere"
}
A nullable field is the union type: ["T", "null"], plain JSON Schema. An embedded object
(json(Entity)) carries its own properties and required.
A view says what it is a view of
The card of a view carries one more key:
"x-fougere-derived": {
"from": "Post",
"nameOf": { "id": "id", "title": "title", "body": null, "status": null }
}
from names the entity the view was cut from, and nameOf says what the cut kept, keyed by the
entity's field names — a dropped field is there, as null. A reader can tell a Post without
body from a Post, and fougere sync writes the view as a view.
The three axes under x-fougere
| key | values | what it states |
|---|---|---|
role.primary | true | identity — the system owns it |
role.unique | [["slug"], ["listId","docId"]] | the unique constraints this field is a member of — one member list each, members named. A lone field is the degenerate case: a constraint of one. The DDL emits them |
role.index | [["title"], ["slug","title"]] | the indexes this field is a member of, listed like unique — the DDL emits them |
role.relation | { to, kind: 'one'|'many', onDelete? } | to is a name, not an inlined schema |
lifecycle.create | { value } | 'now' | { generate } | 'optional' | who writes at creation, and when |
lifecycle.update | 'now' | 'forbidden' | who writes on update |
boundary.in | 'closed' | { decode } | read-only, or a named inbound conversion |
boundary.out | 'closed' | { encode } | write-only, or a named outbound conversion |
A missing key means open: no lifecycle.create means the caller supplies the value; no boundary
means the value goes in and out as it is. Only a declared boundary is written: decoding a
format: date-time field into a Date follows from the shape, and the reader derives it again.
A rule about several fields
A reader sees one field at a time, so a unique group is written whole on each of its members, a
group of one included ([["slug"]]). That is what lets the group be rebuilt from the card alone:
// the card of ListBook — the role.unique of each member
"listId": { "role": { "unique": [["listId", "docId"]] } },
"docId": { "role": { "unique": [["listId", "docId"]] } }
A field in two groups carries two entries. In TypeScript, a field's own unique is read with
Role.of(field).isUnique(), and a group of several with the schema's getUnique().
required semantics
required lists the fields a caller must send at creation: those no lifecycle.create fills,
and that are not a many relation (absent means []). A nullable field stays required — the caller
may send null, not leave the key out.
The bundle — several entities, relations included
Bundle.fromSchemas writes one document with every entity under $defs, each under its lowercased
name — the name relation.to carries.
const bundle = Bundle.fromSchemas({ post: Post, author: Author });
const { post, author } = bundle.toSchemas();
A relation is a name, never a nested schema, so Post → Author → Post is two references and no
loop. Card.toSchema keeps the names; Bundle.toSchemas turns them back into the entities.
Serialization limits
A card holds data only, never a function:
- a relation is a name —
() => Authorbecomes"author", resolved inside a bundle; - a generator or a conversion is a name too, looked up in the reader's registry. A name the
reader does not know is refused there:
unknown generator.
rpc.discover — a whole app's card
Every app answers the reserved operation rpc.discover with its card: per frond, the entities its
operations use, the operations you may call, and the facts it announces.
{
"fronds": [{
"name": "blog",
"entities": [
{ "name": "post", "schema": { /* a card */ } }
],
"facades": [{
"name": "post",
"ops": [
{ "name": "list", "kind": "query", "output": { "title": "Post", /* … */ } },
{ "name": "findById", "kind": "query", "output": { "title": "Post", /* … */ } },
{ "name": "create", "kind": "command", "input": { /* a card */ },
"output": { "title": "Post", /* … */ } },
{ "name": "delete", "kind": "command" },
{ "name": "publish", "kind": "command", "description": "Make the post public." }
]
}],
"facts": [
{ "name": "postPublished", "schema": { /* a card */ } }
]
}]
}
An operation always gives its name and its kind — query reads, command writes. The rest
appears when there is something to say:
| Key | When |
|---|---|
input, output | the operation takes or answers a shape — delete answers a boolean, so neither |
description | the method has a doc sentence |
errors | the codes it can refuse |
cardinality | how much it answers: one, maybe, many, page, none |
listens | the fact it is handed — nobody calls it, an announcement does |
An output that is an entity carries the entity's name as its title: that is how a reader ties an
operation to its rows, whatever the address. A facade may hold no entity at all — a health check, a
computation.
entities lists what an operation takes or answers, and nothing else. A table no operation
reaches — the auth tables, typically — stays off the card.
Facts
A fact is reached by no operation, but its frond wrote that it leaves
(Emit<PostPublished>), so its shape is published. That is what lets a listener in
another repository read it without copying the announcer's code.
A remote caller reads facades to know which process answers which address. A fact is not called:
it arrives.
The call contract
A call is a value: (address, operation, invocation) with
invocation = { params, query, input, state }.
createLocalRunnerexecutes strictly locally;createAppRunnerfollows the topology — local facades, remote stand-ins;- a transport carries this value as it is.
The wire format
Process-to-process is JSON-RPC 2.0 on POST /_fougere/call:
// → request
{ "jsonrpc": "2.0", "id": 1, "method": "post.publish",
"params": { "params": { "id": "…" }, "query": {}, "input": null, "state": { "user": { … } } } }
// ← success
{ "jsonrpc": "2.0", "id": 1, "result": { "id": "…", "status": "published", … } }
// ← domain failure — revived as FougereError on the calling side
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32000,
"data": { "code": "CONFLICT", "message": "Already published",
"address": "post", "operation": "publish" } } }
method is address.op, and params holds the invocation. A browser sends the same frame, but the
server ignores its state and builds its own from the request.
The card and this frame are all a process must honour to answer as a frond — in any language: see A frond in another language.