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

keyvalueswhat it states
role.primarytrueidentity — 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 — () => Author becomes "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:

KeyWhen
input, outputthe operation takes or answers a shape — delete answers a boolean, so neither
descriptionthe method has a doc sentence
errorsthe codes it can refuse
cardinalityhow much it answers: one, maybe, many, page, none
listensthe 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 }.

  • createLocalRunner executes strictly locally;
  • createAppRunner follows the topology — local facades, remote stand-ins;
  • a transport carries this value as it is.
One call, both topologies. The hop is inserted; the facade that validates, binds, presents and projects is the same one on either side.
One call, both topologies. The hop is inserted; the facade that validates, binds, presents and projects is the same one on either side.

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.

Built with Fougere and Nuxt, hosted on GitHub Pages.