Dix-sept jours pour construire VLM : une app de chorale hors-ligne d'abord, avec l'IA comme binôme#
Dix-sept jours.
C'est ce que compte l'historique git de VLM — une application mobile pour les chorales luthériennes malgaches, aujourd'hui en production. Dix-sept journées où j'ai réellement touché au code, sur des soirées et des week-ends, autour d'un travail par ailleurs. L'équivalent de trois semaines et demie de travail effectif.
Ce que ces dix-sept jours ont produit : trente-sept commits, environ 12 800 lignes côté application, 2 300 côté base de données et fonctions serveur. Un répertoire multimédia, une messagerie temps réel, une administration multi-organisations, un mode hors ligne complet, un backend qui a déménagé une fois et un service d'e-mail qui a changé trois fois.
(Ces dix-sept jours sont étalés entre fin mai et fin juillet 2026, avec un trou de seize jours au milieu et un autre de neuf. Je donne le temps effectif plutôt que la durée calendaire, parce que c'est le seul des deux qui apprend quelque chose — et parce que c'est précisément ce que change le fait de travailler avec un assistant IA sur un projet mené sur son temps libre.)
Voici ce que j'ai décidé, pourquoi, et ce que l'IA a réellement changé dans la façon dont ce projet a été fait.
Le défi#
Une chorale, ce sont des gens qui se retrouvent le samedi après-midi dans une salle paroissiale. Le répertoire circule en photos de partitions sur WhatsApp. Les horaires de répétition circulent en messages vocaux. Un nouveau membre met trois mois à savoir où trouver quoi.
Et surtout : la connectivité n'est pas acquise. À Madagascar, entre le forfait data qu'on économise, la salle en sous-sol et le réseau qui tombe, une application qui exige une connexion pour afficher les paroles d'un chant est une application inutilisable au moment précis où on en a besoin.
Cette contrainte a été posée en premier, avant la moindre ligne de code : l'application doit être pleinement utile en mode avion. Tout le reste en découle.
L'objectif#
Quatre choses, pas plus :
- Un agenda public. Consultable sans compte, sans installation, sans rien. Un chœur a besoin de rayonner vers l'extérieur ; un agenda derrière un mur d'authentification ne sert qu'aux gens déjà là.
- Un répertoire complet. Chaque pièce avec ses paroles, sa partition PDF, son enregistrement et sa version instrumentale — l'instrumental étant ce qui permet de travailler sa voix chez soi.
- Une messagerie interne. Groupes par pupitre, messages directs, sans quitter l'application.
- Une administration réelle. Rôles, invitations par e-mail, demandes d'adhésion, droits de téléchargement.
Le tout multi-chœurs dès le départ : une organisation = une chorale, avec ses membres, son répertoire et son agenda isolés des autres.
La stack, et pourquoi#
- Expo SDK 56 / React Native 0.85 / React 19.2, avec le React Compiler et les routes typées activés. Le React Compiler explique l'absence quasi totale de useCallback et memo dans le code écran — une seule exception délibérée, sur laquelle je reviens.
- Expo Router en navigation, avec des groupes de routes et une pile de modales. La coquille à onglets est un détail amusant : ce sont des top tabs Material rendus en bas de l'écran, avec une barre entièrement custom par-dessus. C'est le seul moyen d'obtenir gratuitement le glissement horizontal entre onglets tout en gardant l'apparence d'une barre native.
- Supabase : Postgres avec Row Level Security, Realtime, Edge Functions en Deno, GoTrue pour l'authentification.
- Cloudflare R2 pour les médias (audios, instrumentaux, partitions, photos).
- TanStack Query pour l'état serveur, persisté sur MMKV. Pas de Redux, pas de Zustand, pas de Jotai. Trois couches seulement : l'état serveur dans Query, l'identité dans deux contextes React, le reste en useState.
- Aucune bibliothèque de style. Pas de NativeWind, pas de Tamagui, pas de styled-components. Un StyleSheet classique et trois fichiers de jetons : la palette (bleu royal, or liturgique, papier crème), une échelle typographique de treize entrées, et un fichier de couleurs par tessiture — Soprano, Alto, Ténor, Basse ont chacun leur triplet de couleurs.
Sur la typographie, une décision assumée : Noto Serif pour le contenu, Inter pour le châssis. Les titres, les cartes, les paroles sont en serif ; les libellés, les métadonnées, les badges sont en sans-serif. Une application de chant liturgique n'a pas à ressembler à un tableau de bord SaaS.
L'offline-first, ce n'est pas un cache — ce sont quatre systèmes#
C'est la partie dont je suis le plus content, et celle qui m'a le plus appris. Le dossier src/lib/offline/ fait environ six cents lignes — plus deux points d'ancrage ailleurs dans le code, dans la couche de mutations et dans la racine de l'application — et il contient quatre mécanismes indépendants, avec quatre modes de défaillance distincts.
1. Le cache de requêtes persisté#
TanStack Query sérialisé dans MMKV — pas AsyncStorage, parce que le cache persisté plus l'historique des messages dépassent la limite de curseur de 6 Mo d'AsyncStorage sur Android, et parce que MMKV est synchrone.
Les réglages sont le contrat offline lui-même :
queries: {
staleTime: 30_000,
gcTime: Infinity,
networkMode: 'offlineFirst',
refetchOnWindowFocus: false,
retry: 1,
}gcTime: Infinity combiné à maxAge: Infinity sur le persisteur signifie : on n'expulse jamais rien. Un écran déjà visité s'affiche instantanément au démarrage à froid, en mode avion.
Le prix à payer, c'est un cache dont la forme peut devenir incompatible avec le code. D'où un compteur de version du cache — et la version 2 existe pour une raison très concrète : une liste d'appartenances vide, mise en cache avant que l'adhésion d'un membre soit approuvée, renvoyait un membre pourtant accepté vers l'écran « pas encore de chœur », lequel démontait le composant chargé de refaire la requête. Un cul-de-sac parfait. La correction tient en une ligne dans la garde d'accès :
// Le cache est persisté : à la connexion il peut rendre une liste
// périmée d'avant l'approbation de l'adhésion.
if (!hasActive && isFetching) return null;2. Le miroir média#
Les fichiers audio et les partitions sont recopiés sur le stockage de l'appareil, dans un miroir adressé par la clé de stockage distante, avec un manifeste tenu dans MMKV. Trois détails qui comptent :
- La fraîcheur se vérifie par HEAD + ETag, avec le repli correct pour l'offline : ETag inconnu (pas de réseau, pas d'en-tête) → on fait confiance à la copie locale. Un miroir offline qui refuse de servir un fichier parce qu'il n'a pas pu vérifier sa fraîcheur n'a aucun intérêt.
- Un pool de trois workers écrit à la main plutôt qu'un Promise.all sur toute la liste, pour ne pas saturer le réseau d'un téléphone d'entrée de gamme.
- Le manifeste est écrit après chaque fichier, pas à la fin. Si l'utilisateur tue l'application au milieu d'une synchronisation, la progression est conservée.
Le nom de fichier local dérive de la clé de stockage distante, qui suit un schéma déterministe. Ce n'est pas un hasard : le fait que la couche d'upload produise des clés déterministes est exactement ce qui rend la dérivation de nom du miroir sûre. Deux modules qu'on croirait indépendants sont en réalité couplés par une convention.
Et l'ensemble reste transparent pour le reste de l'application grâce à une seule fonction : localUriFor(url) ?? url. Le lecteur audio, le visualiseur PDF et la visionneuse d'images passent tous par là et ne savent pas si le fichier est local ou distant. Pour les images, la résolution retombe d'ailleurs toujours sur l'URL distante : la synchronisation ne mirroite que les audios, les instrumentaux et les partitions. Les photos d'événements ne sont pas encore préchargées.
3. Les envois en file d'attente#
C'est le piège le plus intéressant du projet.
TanStack Query sait mettre une mutation en pause quand on est hors ligne, et la persister. Deux pièges se cachent là-dedans, et j'ai mis les deux pieds dans le premier — assez longtemps pour qu'il survive à ma propre relecture.
Le mode réseau ne fait pas ce que son nom dit. Le défaut de l'application est networkMode: 'offlineFirst', et c'est le bon choix pour les lectures : tenter sa chance, puis se rabattre sur le cache. Pour une mutation, c'est exactement l'inverse de ce qu'on veut. La requête part quand même sans réseau, échoue — et comme les mutations ont retry: 0 par défaut, le retryer rejette au lieu de mettre en pause. L'envoi hors ligne échouait donc sur place : bulle optimiste annulée, alerte « Envoi impossible », et rien qui n'entre jamais dans une file. C'est networkMode: 'online', posé sur cette mutation précisément, qui produit la pause — parce qu'il fait répondre non à canFetch() avant que la requête ne parte. Les autres mutations gardent l'échec immédiat, volontairement : un administrateur qui crée un groupe hors ligne doit être prévenu, pas laissé sur un indicateur d'attente.
Une mutation persistée perd sa fonction — on ne sérialise pas du code. Au redémarrage, l'hydratation ne reconstruit la mutation qu'à partir de sa clé : la fonction et le mode réseau doivent donc tous les deux venir des défauts attachés à cette clé.
// La mutation en pause est persistée, mais pas sa fonction :
// il faut la réenregistrer avant que resumePausedMutations tourne.
client.setMutationDefaults(SEND_MESSAGE_KEY, { ...SEND_MESSAGE_DEFAULTS });et la reprise se déclenche au succès de la restauration, pas au montage. Corollaire qu'il valait mieux traiter tout de suite : comme seule cette mutation a une fonction rattachée à sa clé, le persisteur ne sérialise qu'elle. N'importe quelle autre mutation mise en pause reviendrait après un redémarrage sans fonction, et rejetterait en silence.
Troisième piège, plus discret. Les bulles optimistes utilisaient Date.now() comme identifiant temporaire. Ça tient tant qu'on envoie un message à la fois — mais quand cinq messages hors ligne se vident d'un coup au retour du réseau, deux d'entre eux tombent dans la même milliseconde et leurs clés React entrent en collision. Un compteur monotone suffit :
let tempSeq = 0;
// ...
id: `temp-${Date.now()}-${tempSeq++}`,Côté interface, tout cela est visible : les messages en attente s'affichent en semi-transparence avec la mention « Envoi… », et une barre dorée au-dessus du champ de saisie compte combien il en reste. L'utilisateur n'a jamais à se demander si son message est parti.
4. La politique réseau#
NetInfo alimente le onlineManager de TanStack Query, avec une nuance : isInternetReachable peut être null pendant quelques centaines de millisecondes au démarrage. Le traiter comme « hors ligne » produit un faux négatif systématique à chaque lancement. On ne considère hors ligne que ce qui est explicitement false.
Le même module met en cache le type de connexion, ce qui alimente la règle « Wi-Fi uniquement » — et là, une décision de produit devenue une décision d'architecture : la même règle a deux comportements différents selon qui la déclenche.
- La synchronisation automatique en arrière-plan saute silencieusement si on n'est pas en Wi-Fi. Personne ne veut d'une boîte de dialogue surgissant sans raison.
- Une action délibérée de l'utilisateur (« Synchroniser maintenant », « Télécharger ») demande confirmation plutôt que d'échouer en silence. Un geste explicite ne doit jamais être ignoré sans explication.
Et quand le type de connexion n'est pas encore connu, on considère que ce n'est pas du Wi-Fi : on préfère demander pour rien que dépenser le forfait de quelqu'un sans lui demander.
Trois morceaux de bravoure#
Deux moteurs PDF, une même astuce WebView#
Il fallait deux choses avec des PDF : extraire le texte d'une partition pour pré-remplir les paroles, et afficher la partition.
Problème : pdf.js ne tourne pas sur Hermes. Il lui manque Promise.withResolvers, DOMMatrix, structuredClone — c'est une bibliothèque de navigateur, elle veut un navigateur.
La solution, dans les deux cas : une WebView, dans laquelle on injecte le PDF en base64 et d'où on récupère le résultat par postMessage. Un vrai environnement navigateur, à l'intérieur de l'application. Pour l'extraction, elle est invisible — taille nulle, pointerEvents="none" — puisque seul le texte renvoyé compte ; pour l'affichage, c'est elle qu'on regarde.
Deux détails que je n'aurais jamais devinés sans les rencontrer :
- pdf.js est épinglé en 3.11.174, la dernière version UMD. À partir de la v4, la bibliothèque n'est distribuée qu'en ESM (.mjs) — et une balise <script> classique ne peut pas l'exposer comme variable globale.
- La WebView reçoit une URL de base en https, pas du HTML nu. WKWebView sur iOS bloque le chargement de scripts et de workers distants depuis une origine nulle. Sans cette ligne, ça marche sur Android et échoue silencieusement sur iOS.
Pour la variante offline, il fallait aller plus loin : la bibliothèque pdf.js et son worker sont téléchargés une fois et stockés dans MMKV, puis le worker est reconstruit dans la WebView à partir d'un Blob et d'un URL.createObjectURL. Zéro accès réseau. C'est ce qui permet d'afficher une partition en mode avion.
Note honnête : il n'y a pas d'OCR. Un PDF qui est en réalité une image scannée ne donne rien, et le message d'erreur le dit. C'était un arbitrage de portée assumé.
Faire glisser un curseur audio sans redessiner la forme d'onde#
Le lecteur audio affiche une forme d'onde de 64 barres. Naïvement, faire glisser le curseur dessus provoque un rendu React par image — 64 nœuds redessinés soixante fois par seconde, sur un téléphone d'entrée de gamme.
Le geste tourne donc entièrement sur le thread UI : il écrit la fraction affichée dans une shared value Reanimated, sans jamais passer par React. La forme d'onde est rendue deux fois — une rangée grise en fond, une rangée bleue à l'intérieur d'un conteneur dont seule la largeur est animée, la rangée interne restant épinglée à la largeur totale pour que les barres ne se réorganisent pas quand le masque se rétrécit.
Le chrono du temps écoulé est le détail que je préfère : c'est un TextInput animé, piloté par useAnimatedProps, avec un formateur mm:ss déclaré 'worklet'. Le texte se met à jour sur le thread UI. Zéro rendu React pendant tout le glissement.
Reste le bug classique : on relâche le doigt, on appelle seek, et le curseur revient une fraction de seconde à l'ancienne position avant de sauter à la bonne — parce que le lecteur n'a pas encore rattrapé sa nouvelle position et continue d'émettre l'ancienne. La correction est un verrou : tant que le temps rapporté n'est pas arrivé à moins de 0,4 s de la cible, on suspend la synchronisation position → affichage.
Enfin, la lecture continue en arrière-plan et sur écran verrouillé, avec les commandes système. L'activation est détectée défensivement à l'exécution :
if (typeof lockable.setActiveForLockScreen !== 'function') return;parce qu'une mise à jour JavaScript envoyée en OTA peut atterrir sur un binaire natif compilé avant que ce module existe. Une application qui reçoit des mises à jour over-the-air doit supposer que son propre code natif est plus ancien qu'elle.
Ce que RLS ne peut pas exprimer#
Le 20 juin, j'ai passé une journée entière à ne rien construire et à tout relire. Deux failles réelles en sont sorties.
La première : l'auto-approbation d'organisation. Une organisation est créée avec le statut « en attente » et doit être validée par e-mail. Mais la politique d'insertion RLS d'origine ne contraignait que le champ « créé par », pas le champ « statut ». N'importe quel client pouvait faire un insert direct via PostgREST avec un statut déjà approuvé et court-circuiter complètement le circuit de validation. La correction : supprimer la politique d'insertion. La création d'organisation passe désormais exclusivement par une fonction SECURITY DEFINER qui impose le statut.
La seconde : l'accès aux données avant approbation. La fonction d'aide is_member_of(org) — qui sert de prédicat à quasiment toutes les politiques de lecture — vérifiait le statut de l'organisation, mais jamais celui du membre. Résultat : quelqu'un qui avait simplement demandé à rejoindre un chœur, sans qu'aucun administrateur ait accepté, pouvait lire son répertoire, ses discussions, ses messages et sa liste de membres. La correction est un prédicat de plus, and om.status = 'approved' — et elle règle le problème pour toute la base d'un coup, précisément parce que cette fonction est le point de passage unique. C'est l'argument pour centraliser ses prédicats RLS dans une fonction plutôt que de les recopier dans chaque politique.
Trois autres décisions du même registre :
- Les fonctions serveur ne font pas confiance au JWT seul. Elles agissent avec la clé de service, qui contourne RLS. Un JWT valide prouve que l'appelant est un utilisateur connecté — pas qu'il a le droit d'agir sur l'organisation qu'il a mise dans le corps de la requête. Chaque fonction sensible reconstruit donc un second client Supabase lié à l'en-tête Authorization de l'appelant, et relit son appartenance sous ses propres règles RLS avant d'agir.
- Des invariants au niveau base que RLS ne sait pas dire. La politique d'écriture vérifie que l'acteur est administrateur — mais elle n'empêche pas un administrateur de modifier sa propre ligne. Deux triggers Postgres interdisent l'auto-rétrogradation et l'auto-retrait, avec des messages d'erreur en français directement remontés à l'interface.
- La session dans le trousseau, pas dans AsyncStorage. Avec un détail non trivial : expo-secure-store plafonne en pratique autour de 2 Ko par entrée (une entrée = une entrée Keychain / Keystore), et une session Supabase sérialisée — jeton d'accès plus jeton de rafraîchissement — dépasse cette taille. Il a fallu écrire un adaptateur qui découpe la valeur en morceaux numérotés, stocke le nombre de morceaux dans une clé voisine, nettoie les morceaux résiduels à l'écriture, et traite une écriture partielle comme une absence. Coût assumé et documenté : les sessions existantes ne survivent pas à la migration, chacun se reconnecte une fois.
Les uploads, enfin, ne passent jamais par des identifiants embarqués dans l'application. R2, comme S3, n'a pas de modèle d'écriture sûr côté client : une fonction serveur signe une URL de dépôt valable dix minutes, après avoir vérifié que l'appelant est bien administrateur approuvé de l'organisation visée, et avoir refusé toute tentative de traversée de chemin.
Trois transports e-mail, et une limite de 2 secondes#
Voilà l'histoire que je raconterais autour d'une bière.
L'application envoie deux familles d'e-mails complètement différentes : les e-mails d'authentification (le code à 6 chiffres de confirmation, la réinitialisation de mot de passe), envoyés par le service Go de Supabase ; et les e-mails transactionnels de l'application (invitation d'un membre, demande de création d'organisation adressée à l'administrateur de la plateforme, notification d'adhésion acceptée), envoyés par mes fonctions serveur Deno.
Le parcours a été le suivant :
- SMTP direct au départ, pour les deux familles. Le service d'envoi intégré de Supabase plafonne autour de deux à quatre e-mails par heure — inutilisable dès qu'on invite dix personnes d'un coup.
- Passage à un fournisseur SMTP dédié, avec la limite de débit remontée. Ça marche pour l'authentification.
- Et ça ne marche pas pour les fonctions serveur. Les invitations partaient... parfois. Le plus souvent, l'appel restait sans réponse, sans erreur exploitable.
La cause racine a mis du temps à sortir, et elle est excellente : le runtime edge impose une limite dure d'environ 2 secondes de temps CPU par requête. Pas de temps d'attente — de temps CPU. Or envoyer un e-mail en SMTP depuis un runtime serverless, c'est faire une poignée de main TLS en espace utilisateur puis une conversation SMTP en plusieurs allers-retours. C'est du calcul, pas de l'attente. L'isolate se faisait tuer en plein milieu (« CPU time hard limit reached… cancelled by supervisor ») et l'appelant ne recevait jamais rien.
Un appel HTTP vers une API d'e-mail, lui, c'est un seul fetch de 100 à 300 ms — et c'est de l'entrée/sortie, pas du CPU. Passage à une API HTTP : le problème disparaît instantanément.
Puis le crédit d'essai du fournisseur s'est épuisé en pleine phase de test (429 Credit exhausted), rebloquant exactement les mêmes e-mails. Bascule vers Resend, qui a une offre gratuite durable à ce volume et exactement la même forme d'API. Le module d'envoi fait trois fonctions et un fetch : le changement de transport n'a touché aucune fonction appelante.
L'architecture finale est un compromis assumé, et c'est ce que je trouve le plus instructif : SMTP pour les e-mails d'authentification, API HTTP pour les e-mails de l'application. Deux transports, choisis non pas par préférence mais par le côté de la limite CPU où chaque expéditeur se trouve. Le service Go a tout le temps CPU du monde ; le runtime edge, deux secondes.
Un dernier détail qui a coûté cher : quand une fonction serveur échoue, le client Supabase remonte « non-2xx status code » et jette le corps de la réponse — précisément là où se trouve le message d'erreur réel. Il a fallu écrire un extracteur pour aller le rechercher. La qualité de vos messages d'erreur détermine la vitesse de vos diagnostics, et c'est vrai jusque dans le SDK que vous n'avez pas écrit.
Quitter le Supabase hébergé sans toucher à l'application#
Le 9 juillet, le backend a déménagé du Supabase hébergé vers une instance auto-hébergée sur un VPS.
Le piège conceptuel principal, et je suis passé à côté au début : config.toml ne configure que la pile CLI locale. Un déploiement auto-hébergé se configure par variables d'environnement, point. Tous les réglages soigneusement posés dans config.toml — longueur du code OTP, expiration, longueur minimale de mot de passe, limites de débit e-mail, liste d'URL autorisées pour les liens profonds — n'ont strictement aucun effet en auto-hébergé tant qu'on ne les a pas ré-exprimés en variables d'environnement.
Le reste est de l'infrastructure classique, avec quelques choix :
- La passerelle Kong n'est publiée que sur la boucle locale. La terminaison TLS et le routage passent par le Traefik déjà en place, sur un réseau Docker partagé. La configuration de base publiait Postgres sur toutes les interfaces — inutile, et en conflit avec un service existant sur le même port.
- Les modèles d'e-mail sont servis en HTTP depuis un stockage objet, parce que le service d'authentification ne sait récupérer un modèle que par URL : lui donner un chemin de fichier le fait le coller derrière l'URL du site et renvoyer un 401.
- Deux scripts maison remplacent le CLI : un qui applique les migrations en les faisant passer par psql dans le conteneur, un qui synchronise les fonctions dans le volume du runtime et redémarre le service.
Côté application : deux variables d'environnement, zéro ligne de code modifiée. C'est le seul indicateur de succès qui compte pour ce genre de migration.
Ce qui manque encore, et que je n'ai pas envie de cacher : il n'y a pas encore de sauvegardes automatiques. Un pg_dump en tâche planifiée est trivial à mettre en place, et l'enjeu est faible tant qu'il n'y a pas d'utilisateurs réels — ce qui change précisément maintenant. C'est le tout premier point de ma liste avant l'ouverture au public.
La partie IA : un binôme, pas un pilote#
Ce projet a été construit avec un assistant IA de bout en bout. Trente-sept commits sur dix-sept journées, gros et thématiques plutôt que granulaires : une session de travail, une fonctionnalité complète, un commit. C'est visible dans l'historique et ce n'est pas un accident — c'est le rythme naturel de ce mode de travail, et c'est la forme que prend un projet mené sur son temps libre. Quand on ne dispose que d'une soirée, on ne peut pas se permettre de la passer à recontextualiser. Le commit thématique n'est pas un choix esthétique : c'est ce qui permet de reprendre trois semaines plus tard sans avoir à relire tout le dossier.
Voici ce que j'en retiens, sans enthousiasme excessif ni scepticisme de posture.
Le fichier d'instructions le plus important fait deux lignes#
Le fichier AGENTS.md du dépôt dit ceci, et rien d'autre :
Expo a changé. Lisez la documentation exacte de la version v56 avant d'écrire la moindre ligne de code.
Ça ressemble à une cicatrice, et c'en est une. Le premier défaut d'un modèle sur un SDK qui bouge vite, ce n'est pas l'incompétence : c'est la confiance dans un savoir périmé. Il produit du code Expo SDK 50 parfaitement idiomatique, parfaitement plausible, et parfaitement faux pour la version installée. Il ne signale rien, parce que de son point de vue il n'y a pas de doute.
Le correctif n'est pas de mieux formuler ses demandes. C'est d'ancrer l'assistant à une source versionnée et d'en faire une règle permanente du dépôt. Deux lignes ont eu plus d'impact sur la qualité du code que n'importe quel prompt élaboré.
Ce que l'IA fait remarquablement bien#
- Le squelette. Le 27 mai, environ quatre-vingts minutes après le git init, le dépôt contenait déjà l'authentification, les quatre onglets, et une migration SQL initiale avec le schéma complet, les politiques RLS et les fonctions d'aide. Ce n'était pas jetable — l'essentiel de cette structure a survécu jusqu'à aujourd'hui.
- Les couches répétitives. Le fichier de traduction unique, les écrans de formulaire qui se ressemblent, et surtout les passes transversales : ajouter un état de chargement et un état d'erreur cohérents sur une trentaine d'écrans est exactement le genre de travail où un humain se fatigue et devient incohérent au dixième fichier.
- Le SQL avec sa justification. Les dix-neuf migrations portent chacune, en commentaire, la raison de son existence. C'est le genre de discipline que je tiens rarement seul sous pression.
Ce qu'il faut piloter à la main#
Toutes les décisions les plus intéressantes de ce projet — la limite de 2 secondes de CPU du runtime edge, le blocage WKWebView sur les origines nulles, l'absence de DOMMatrix dans Hermes, les 2 Ko par entrée du trousseau, l'ETag inconnu qu'il faut traiter comme « fais confiance au cache » — aucune n'est arrivée gratuitement. Chacune vient d'un bug observé sur un appareil réel, d'une session de diagnostic, et d'une hypothèse à formuler à la main.
L'IA est excellente pour écrire la correction une fois qu'on sait ce qui ne va pas. Elle est très moyenne pour deviner qu'un e-mail qui ne part pas, sans erreur, est un problème de budget CPU et non de configuration SMTP. Le savoir sur les limites de plateforme n'est pas dans les données d'entraînement — il est dans les traces d'exécution de votre propre application.
La journée d'audit, comme méthode#
La meilleure chose que j'aie faite sur ce projet tient en une journée : le 20 juin, je n'ai rien construit. J'ai fait relire l'intégralité du code sous des angles adverses et séparés — sécurité d'abord, montée en charge ensuite, maintenabilité enfin — au lieu de demander un vague « est-ce que ce code est bon ? ».
Le résultat de cette seule journée :
- Les deux failles réelles décrites plus haut, corrigées.
- Cinq index manquants ajoutés, avec l'explication de pourquoi les clés primaires composites existantes ne pouvaient pas servir ces recherches.
- La prévisualisation de conversation dénormalisée via un trigger Postgres : la liste des discussions lisait auparavant l'historique des messages pour afficher le dernier message de chaque fil. Désormais le trigger écrit l'aperçu sur la ligne du canal à chaque insertion.
- 347 lignes de données factices supprimées. Toute l'application est passée en données réelles ce jour-là.
- Et 690 lignes de commentaires bruyants purgés sur l'ensemble du dépôt.
Cette dernière ligne est celle dont je parle le plus. Un assistant IA produit des commentaires, beaucoup, et la plupart paraphrasent le code (« // incrémente le compteur »). Ils ne sont pas faux ; ils sont du bruit, et le bruit dilue le signal.
La règle qui est sortie de cette purge, et qui tient depuis : un commentaire ne survit que s'il explique un pourquoi, une limite de plateforme, ou un bug réellement rencontré. Ce qui reste dans le dépôt, ce sont exactement les paragraphes que j'ai cités dans cet article. C'est ce qui rend la base de code relisible — par un humain dans six mois, et par le prochain agent qui l'ouvrira sans contexte. Un commentaire qui explique un piège est un prompt permanent.
La conclusion honnête#
L'IA a compressé le temps de mise en œuvre. Elle n'a pas compressé le temps de compréhension.
Dix-sept journées, sur son temps libre, pour une application de cette portée — répertoire multimédia, messagerie temps réel, administration multi-organisations, mode hors ligne complet et backend auto-hébergé — ce n'est pas un rythme atteignable seul sans cet outil. C'est le point que je ne veux pas minimiser : ce projet n'aurait pas existé. Pas « aurait pris plus longtemps » — il serait resté une idée, parce qu'un projet de soirées qui demande six mois avant le premier écran utilisable meurt avant d'y arriver.
Mais le goulot d'étranglement s'est simplement déplacé. Il n'est plus dans l'écriture du code : il est dans la revue, dans le test sur appareil réel, et dans la capacité à repérer une décision plausible mais fausse avant qu'elle ne s'enracine. Le temps que je ne passe plus à écrire des écrans de formulaire, je le passe à lire du code que je n'ai pas tapé et à me demander ce qu'il suppose.
Et ce déplacement a un effet secondaire qu'on sous-estime quand on travaille par intermittence : la partie que l'IA accélère est celle qu'on peut faire fatigué, le soir. La partie qu'elle n'accélère pas est celle qui demande d'être frais. La journée d'audit du 20 juin n'aurait rien donné à 23 h. Les seize jours d'interruption de juillet n'ont pas été du temps perdu — au retour, j'ai vu dans mon propre code des choses que je n'y voyais plus.
C'est un bon échange. Ce n'est pas un échange gratuit.
Ce qui n'est pas dans la v1#
Par honnêteté, et parce qu'un article qui ne liste que les réussites n'apprend rien :
- Pas de notifications push. Aucune dépendance installée, l'entrée correspondante du profil est commentée dans le code, et le carillon en en-tête du fil d'actualité a été délibérément retiré plutôt que laissé comme un bouton mort.
- Pas de pièces jointes dans les discussions. Le modèle de données les prévoit, l'interface sait les afficher, mais rien dans l'application n'en crée. Une demi-fonctionnalité assumée comme telle.
- Pas de programme d'événement ni de gestion des présences. La chaîne de traduction « Programme » existe, inutilisée, et le nombre de participants est figé à zéro dans le mappage des données.
- Pas de photos d'événements hors ligne. Le miroir média couvre les audios, les instrumentaux et les partitions — pas les images.
- Pas d'iOS. Le projet compile et tourne sur iPhone, mais il n'y a pas encore de circuit de publication App Store. Android d'abord, iOS ensuite.
- Pas de sauvegardes automatiques de la base, comme dit plus haut.
Et maintenant#
VLM entre en phase de test avec de vrais choristes, sur de vrais téléphones, avec de vraies conditions de réseau — c'est-à-dire dans le seul environnement où l'hypothèse fondatrice de ce projet peut être validée ou démolie.
J'ai publié un guide à destination des testeurs, qui détaille les scénarios à couvrir et notamment le protocole de test hors ligne complet.
VLM est née à VLM Ankatso et développée par le Groupe Kirakira.
Soli Deo gloria.


