Pré-version — les API se stabilisent

Le seul framework que vous pouvez choisir n'importe quand.

Jour 1 ou jour 400 : vos routes, vos pages, votre rendu et vos données ne bougent pas. Ce que vous réécrivez, c'est le modèle — une entité à la fois.

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

Une seule idée, et du genre qui se vérifie

La déclaration ne nomme rien en dehors d'elle-même — ni table, ni protocole, ni hôte, ni adresse. Ce qu'elle ne nomme pas est donc soit dérivé d'elle, soit choisi en dehors. Les deux sections qui suivent sont ces deux lectures, et rien d'autre.

SQLremotes:Mirrorcall envelopeRESTGraphQLla Frondla surface publiqueles ports qu'elle traverse

Première lecture — ce qui en dérive

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.storage.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 '@fronds/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

Seconde lecture — ce qui est choisi en dehors

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.

le même processvotre pagela Frondun autre processun autre dépôtune autre langue
  • 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 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 d'appel : useQuery, useCommand, useFormFor et useCurrentUser dans la page, et invoke, leur dual serveur

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.