Structure
convex/ Backend Convex
schema.ts Tables et index
http.ts Le routeur HTTP : il assemble les routes que chaque fonctionnalité enregistre (`features/<nom>/routes.ts`)
auth.ts, auth/ Better Auth et ses e-mails
extensions.ts Points d'accroche (voir Extensions)
_lib/ Le socle : constructeurs de fonctions (auth, triggers) et validators du schéma
features/<nom>/ Une fonctionnalité : queries, mutations, actions, internal
lib/<domaine>/ Le code partagé d'un domaine (leads, email, security, extensions…), sans fonction Convex
seed/, setup/ Premier employé, assistant de configuration initiale
src/
design-system/ Composants d'interface
widgets/ Auth, layouts, providers
features/<nom>/ Une fonctionnalité : components/, hooks/, lib/, types.ts
pages/ Une page par route : elle compose les composants de sa fonctionnalité, elle n'en définit pas
lib/ backend.ts (ré-exports Convex), erreurs, navigation, pays
tests/ backend/ (convex-test), frontend/, setup.ts (voir Tests)
scripts/ Générateurs (OpenAPI, formats d'adresse)
docker/ Caddyfile + entrypoint de l'image de production
docs/ Contrats : API publique (openapi.yaml), extensions, connecteurs, secrets
Alias d'import : @crm/* → ./src/* (déclaré dans tsconfig.json et
vite.config.mts).
Où va un nouveau fichier du backend : une fonction Convex (query, mutation,
action) dans features/<fonctionnalité>/, ses routes HTTP dans son
routes.ts ; ce que plusieurs fonctionnalités
partagent dans lib/<domaine>/, importé par son module, sans fichier
d'index ; un validator de table dans _lib/validators/.
Les imports descendent : lib/ n'importe aucune fonctionnalité, et les
validators et les constantes de _lib/ n'importent ni lib/ ni features/
(tests/backend/layering.test.ts).
Côté interface : un composant, un hook ou un helper va dans
src/features/<fonctionnalité>/ (un composant par fichier) ; src/pages/ ne
garde que le composant de la page. Le backend n'est importé que par
src/lib/backend.ts.
Les règles que le code suit, chacune gardée par un test :
- Un fichier écrit à la main tient en 400 lignes, ou dit pourquoi il en
fait plus (
tests/backend/fileSize.test.ts). - Une fonction Convex déclare
argsetreturns(tests/backend/returnValidators.test.ts). Une ligne stockée se déclare avecdocOf('<table>')(convex/lib/shared/docs.ts), une forme déjà nommée par son validator. - Un refus que la personne peut corriger est levé avec
refusal(code)(convex/_lib/refusal.ts), jamais avecnew Error('code'): en production, Convex ne transmet au client que le contenu d'un refus, unErrory arrive sous la forme « Server Error ». Le détail va dansreason, une phrase destinée à la personne dansmessage. L'interface lit le tout avecerrorLabeletdescribeError(src/lib/errors.ts). UnErrorreste ce qu'il est : une anomalie. - Une règle que l'interface et le backend vérifient tous deux s'écrit une
fois, en zod, dans
convex/_lib/validators/fields.ts(adresse e-mail, URL, couleur, pays, devise, entier borné ou positif, quantité non négative, jour). Ce qu'une étape de workflow doit contenir avant l'activation est dansconvex/_lib/validators/workflowSteps.ts: l'éditeur l'affiche sous l'étape, le backend refuse l'activation avec la même phrase (tests/frontend/workflowStepRules.test.ts). - Un contact se crée à un seul endroit,
createLeadRecord(convex/lib/leads/records.ts) : la ligne, son entrée au journal, la première ligne de son historique de statut, puis les workflows (tests/backend/leadCreation.test.ts). - Une query ou une mutation ne lit que par son
ctx.db, soumis aux règles de visibilité de l'appelant. Ce que le déploiement compte (un plafond) est une query interne qui ne rend que des nombres ; chacune est listée, avec sa raison, danstests/backend/layering.test.ts. - Une query ne lit une table entière, ou toute une plage d'index, que là où
c'est petit par nature, et le dit : chaque
.collect()est listé avec sa raison danstests/backend/collects.test.ts; plus aucune lecture connue ne grandit avec l'usage. - Les compteurs d'une campagne vivent hors de la campagne
(
campaignStatShards, seize lignes sommées à la lecture,convex/lib/campaigns/stats.ts) : un événement du fournisseur écrit une de ces lignes, jamais le document de la campagne, et un lot (envoi, renvoi) n'en écrit qu'une par transaction (tests/backend/campaignStats.test.ts). - Une écriture de contact ne coûte que ce qu'elle change
(
tests/backend/writeCost.test.ts) : un contact est écrit une fois à sa création, un agrégat n'est touché que si la ligne y change de place, et les déclencheurs ne lisent que les listes dynamiques et les workflows actifs. Conséquence : une ligne écrite hors des mutations (tableau de bord Convex,convex import) n'entre dans les compteurs qu'à sa prochaine écriture qui la déplace (propriétaire, statut, entreprise, stade, montant, suppression). - Chaque page se charge à la demande (
src/app.tsx) ; le build échoue si un fichier dépasse 500 kB (vite.config.mts). - Une page n'ouvre que les requêtes vivantes qu'elle montre : une
boîte de dialogue fermée n'en ouvre pas (
useEmployees(open)), et les options d'un champ sont chargées par son contrôle, quand il s'affiche (LeadListMultiSelect,CompanyMultiPicker). - Une ligne de grande liste est mémoïsée (
memo) et ne reçoit que des props stables (useCallback,useMemo) : une case cochée, une boîte de dialogue ouverte ou un compteur qui bouge ne re-rend pas la liste. Une requête vivante rend de nouveaux objets à chaque mise à jour : là où les mises à jour sont fréquentes (page d'une campagne), la ligne compare ce que l'objet contient (src/lib/sameRow.ts). La valeur d'un contexte est mémoïsée, et ce qui change souvent a son propre contexte (la sélection du canevas des workflows). - Une liste modifiable n'est pas indexée par position : une règle de
filtre garde sa clé à travers ses modifications
(
src/features/filters/lib/rowKeys.ts). - Une page ou une fonctionnalité peint avec les jetons du thème
(
src/design-system/theme.css), jamais avec une couleur écrite à la main (tests/frontend/colourTokens.test.ts).