Pré-version — les API se stabilisent

Écrivez le domaine. Tout en dérive — jusqu'au process où il tourne.

Une classe déclare l'objet métier : validation, table, formulaire et surfaces en dérivent. Le jour où il doit partir dans son propre process — ou dans un autre langage — le code qui l'appelle ne bouge pas.

Le code ci-dessous est la Frond blog de ce site — pas du pseudocode.

Le modèle : une déclaration, tout se projette

Une entité n'est pas une table. La table, la validation, le formulaire, l'API sont des projections d'une seule déclaration — changez-la, chaque projection suit.

Le schéma — déclaré une fois

class Post extends entity({
  id: primary(),
  title: text({ min: 1 }),
  status: readOnly(oneOf(
    'draft', 'published')),
}) {}

Post.validate(input)dérivée du shape · embarquée avec la classe

Surface d'API

post.list · post.publish

Table de base de données

auto-DDL → SQLite

Type TypeScript

function render(p: Post)

Contrat de formulaire

useFormFor(Post)

Type GraphQL

type Post { … }

Désignation & DI

useQuery(Post, 'list')

Un noyau, six projections — changez la déclaration, chaque projection suit.

1 Déclarer

Une classe d'entité. Validation, table SQLite, type GraphQL, contrat de formulaire — toutes ses projections.

fronds/blog/entities/Post.ts
class Post extends entity({
  id: primary(),
  slug: text({ min: 1, max: 80 }),
  title: text({ min: 1, max: 160 }),
  authorId: readOnly(text()),
  status: readOnly(oneOf('draft', 'published',
    { default: 'draft' })),
  publishedAt: readOnly(optional(date())),
}) {}

2 Juger

Des opérations, pas des écritures de champ. readOnly ferme la porte entrante ; le serveur tamponne la paire.

fronds/blog/handlers/PostHandler.ts
class PostHandler extends Crud(Post) {
  async publish(id: string, user: User | null) {
    if (!user) throw new FougereError({
      code: ErrorCode.UNAUTHORIZED, /* … */ });
    // author-only, draft-only — then realize:
    return this.orm.update(id, {
      status: 'published',
      publishedAt: new Date().toISOString(),
    });
  }
}

3 Consommer

La classe importée désigne l'appel. Une command sur Post revalide toutes les queries sur Post.

app/pages/blog/index.vue
import Post from '@frond/blog/entities/Post';

const { items } = await useQuery(Post, 'list');
const publish = useCommand(Post, 'publish');

await publish.execute({ params: { id } });
// → every mounted query on Post revalidates

Le gradient

Une Frond tourne in-process ou dans son propre process derrière JSON-RPC 2.0 — avec un code utilisateur identique. Pas de RPC sans voyage : en local, l'appel est une exécution mémoire directe.

IN-PROCESSNuxt appuseQuery(Post, 'list')Frond blogappel mémoire direct — zéro sérialisationSPLITNuxt appuseQuery(Post, …):4100Frond blogJSON-RPCla même valeur d'appel, mise sur le fil
remotes: { blog: 'http://127.0.0.1:4100' }— la seule ligne qui change
  • Les erreurs voyagent intactes : même code, message et détails par champ des deux côtés
  • La session atteint les collectors distants — la confiance est intra-topologie
  • Host mort → 503 typée dans vos pages ; relance → récupération, app intouchée

Une Frond n'a pas à être en TypeScript

Une Frond n'honore que deux contrats, et les deux sont du JSON : le fil (JSON-RPC 2.0) et la carte (rpc.discover, qui rend ce qu'elle héberge, schémas compris). Aucun des deux ne mentionne TypeScript. demos/rust-frond est un domaine telemetry écrit en Rust — aucune classe d'entité nulle part, la déclaration vit dans src/main.rs.

Le consommateur demande la carte, en rebâtit un schéma vivant, et refuse un payload avant tout réseau. Ces refus sont les quatre axes traversant une frontière de langage : shape EST le JSON Schema, role, lifecycle et boundary voyagent sous x-fougere. Les règles voyagent, pas seulement les types.

demos/rust-frond — the TS consumer's output
$ npx tsx consumer.ts

 couleur Unknown field
 celsius 250 is greater than 80.
 checksum Read-only
 label String is too short (1 < 2).

Règles déclarées en Rust, tenues par le juge TypeScript — aucune ligne de TS ne les déclare

Ce que le modèle fait disparaître

Une conséquence visible : sans modèle, toute app redéclare la même forme dans le validateur, la table, l'endpoint et le formulaire — quatre fichiers qui ne doivent jamais diverger. Avec, la déclaration est seule et tout le reste dérive.

your-nuxt-app/ — 4 files
// schemas/post.ts — the shape, first time
export const postSchema = z.object({
  slug: z.string().min(1).max(80),
  title: z.string().min(1).max(160),
});

// server/db/schema.ts — the shape, again
export const posts = sqliteTable('posts', {
  slug: text('slug').notNull(),
  title: text('title').notNull(),
});

// server/api/posts.post.ts — wired by hand
const body = postSchema.parse(await readBody(event));

// app/components/PostForm.vue — the rules, again
const rules = { title: [required, maxLength(160)] };

4 déclarations de la même forme, synchronisées à la main

your-fougere-app/ — 1 file
// fronds/blog/entities/Post.ts — the shape, once
class Post extends entity({
  id: primary(),
  slug: text({ min: 1, max: 80 }),
  title: text({ min: 1, max: 160 }),
}) {}

// Derived from it — nothing to keep in sync:
//   validation  (browser + façade, same judge)
//   SQLite table + additive schema sync
//   form contract   useFormFor(Post)
//   API surface     post.create / post.list
//   GraphQL type    type Post { … }

1 déclaration — le reste est dérivé

Ne nous croyez pas — faites compter votre agent

La glue dupliquée est la trace mesurable d'un modèle absent. Collez ce prompt dans l'agent IA qui connaît déjà votre codebase (Claude Code, Cursor…) : il compte les formes redéclarées et le câblage de synchro de votre app — et rapporte les coûts d'adoption avec le même soin.

audit-prompt.md
# Audit: how much schema glue does this repo maintain by hand?

You are auditing THIS repository. Be honest: report the costs
of switching as carefully as the gains.

## Reference model — Fougere, a single-schema TS framework

One class declares a business object once:

    class Post extends entity({
      id: primary(),
      slug: text({ min: 1, max: 80 }),
      title: text({ min: 1, max: 160 }),
      status: readOnly(oneOf('draft', 'published',
        { default: 'draft' })),
    }) {}

Everything derives from it — input validation (the same judge
in the browser and at the API facade, unknown keys refused),
the SQL table (additive auto-DDL; renames, removals and type
changes need an explicit migration), the form contract (fields,
rules, per-field error mapping), the API surface (post.list,
post.create...), GraphQL types, and the TS type (the class IS
the type). Business rules are handler operations, e.g.
publish(id, user), judged server-side. Moving a module to its
own process is one line of config; user code does not change.

Scope today (pre-release): storage is additive auto-DDL over
Kysely. SQLite resolves from its name; Postgres, MySQL and SQL
Server work by handing Fougere the Kysely dialect you built
(setupKysely) — only you have the driver. No search-index
projection; auth via better-auth (credentials + OAuth). Price
the adoption costs against THIS scope, not an imagined one.

If you can fetch the web, ground yourself in the docs first:

- http://localhost/docs — the model in one page
- http://localhost/docs/schema/entities — field vocabulary, the 4 axes
- http://localhost/docs/client/forms — the shared browser/facade judge
- http://localhost/docs/existing-app — the feature-by-feature migration
  path (use it to price the adoption cost honestly)

## Measure, in this repo

1. Identify the 3 most-touched business objects. A repo has
   no traffic stats — use git churn as the proxy (most-modified
   schema/form/handler files over the last ~500 commits),
   crossed with the app's main forms and API routes.
2. For each, list every file where its SHAPE is re-declared:
   validation schema (Zod/Yup/joi), DB table or migration,
   API input/output types, form state and rules, TS
   interfaces, API-client types. Quote the paths. If one
   object's shape belongs to an external system (legacy API,
   search index), audit it anyway and flag it: that is the
   unfavorable case, and it belongs in an honest report.
3. Count the lines that exist ONLY to keep those in sync:
   parse/serialize, DTO mapping, hand-rolled error
   formatting, manual refetch after mutations. Counting
   rule: committed codegen output and pass-through wrappers
   count; business logic in resolvers or computed fields
   does not.
4. Look for one place where two of those declarations
   already disagree (a max length, an optional, a nullable).
   There usually is one — that drift is the argument. If there
   is none here, say so plainly rather than reaching.

## Report

Per object: a table declaration-site → file → lines.
Then two totals, honestly:
- lines deletable under a derive-everything model;
- what adopting Fougere would cost HERE: storage handover
  (SQLite auto-DDL today), pre-release status (not on npm
  yet), the feature-by-feature migration path, what the
  team would have to learn.

Où en est Fougere

Pré-version — mais pas une promesse. Chacun de ces points a été vu tourner :

  • Les 5 primitives client sont le seul chemin — l'ancien monde a été supprimé, pas remplacé à moitié
  • Une feature métier jugée (draft→publish) validée en navigateur, bascule de badge comprise
  • Le split est vécu au quotidien : host tué → 503 typé dans les pages ; relancé → récupération
  • Code utilisateur identique in-process et split — vérifié jusqu'au build de prod
  • Ce site — docs, blog, auth — tourne dessus

4 axes

un champ énonce sa forme, son rôle, son cycle de vie et sa frontière — chaque projection y lit

1 ligne

l'énoncé de topologie entier : remotes.blog = 'http://…'

5 primitives

toute la surface client : useQuery, useCommand, useFormFor, useCurrentUser, invoke

Ce site est une app Fougere

Les docs que vous allez lire sont du markdown dans git. Le blog derrière /blog est une Frond : les posts sont des entités avec une transition draft→publish jugée, écrits via le contrat de formulaire, lus via la primitive de query — et la Frond entière peut partir dans un autre process en décommentant une ligne de config.

Construit avec Fougere — ce site tourne sur le framework qu'il documente.