Audit du modèle métier, 2026-09-11
Lecture du schéma et des chemins qui l'écrivent. Ce document, le garde src/test/store-delete-reaches-every-orphan.test.ts et les items de backlog qu'il ouvre sont la sortie complète. Périmètre : prisma/schema.prisma…
Lecture du schéma et des chemins qui l'écrivent. Ce document, le garde
src/test/store-delete-reaches-every-orphan.test.tset les items de backlog qu'il ouvre sont la sortie complète.Périmètre :
prisma/schema.prisma(155 modèles, 54 enums, 7 279 lignes) et les chemins applicatifs qui décident de l'appartenance d'une ligne à un locataire. Chaque chiffre de cette page a été dérivé du schéma, jamais compté à la main.
Ce que cet audit cherchait
La question posée était : concepts dupliqués, ownership flous, relations
contradictoires, modèles inutiles, sources de vérité multiples. La réponse
courte, et elle surprend : le schéma est sain. Les cascades sont
cohérentes, les @@unique sont là où il faut, et la densité de documentation
par colonne est plus élevée que dans la plupart des dépôts de cette taille.
Ce qui manque n'est pas de la rigueur, c'est de la symétrie. Les défauts trouvés sont presque tous de la même forme : une règle appliquée sur une des trois racines de locataire et pas sur les deux autres.
1. La colonne vertébrale
Trois racines, et rien d'autre ne définit l'appartenance :
User ──owns──> Organization ──owns──> Store
│ │ │
│ OrganizationMember └── 45 enfants directs (FK)
│ (RBAC : owner/admin/member/viewer)
└── 27 enfants directs (FK) 18 enfants directs (FK)
| Racine | Enfants FK directs | Modèles portant sa clé sans FK |
|---|---|---|
Store | 45 | 8 |
User | 27 | 9 |
Organization | 18 | 9 |
Store est le vrai centre de gravité : deux fois et demie plus d'enfants que
l'organisation. C'est cohérent avec le produit — l'organisation facture, le
store est ce que l'IA opère.
Répartition des 155 modèles par la clé de locataire qu'ils portent :
| Classe | Modèles |
|---|---|
Scopés store (storeId seul) | 39 |
Scopés org (orgId seul) | 17 |
| Portant les deux | 9 |
| Scopés user | 15 |
| Scopés par un parent (indirect) | 12 |
| Sans aucun chemin FK vers une racine | 55 |
Les 55 derniers ne sont pas une anomalie en soi : Plan, AIModel,
PlatformConfig, CronExecution, MarketCluster, StatusIncident sont des
données de plateforme, elles n'appartiennent à personne par construction. Mais
la liste contient aussi Conversation, OAuthAccessToken, BrowserSession,
AlgoTrace, MemoryEvent et ApiToken — qui portent, eux, des données de
locataire. C'est le constat 2.
2. L'ownership flou : 26 colonnes, 19 modèles
Dix-neuf modèles déclarent une colonne orgId / storeId / userId que
aucune @relation ne lie. Postgres n'en sait donc rien : ni contrainte
d'intégrité, ni cascade.
Une partie est délibérée et écrite comme telle dans le schéma —
OrganizationArchive (« no FK on purpose : the org row is gone by design »),
AffiliateCommission (« un ledger survit à l'entité qu'il désigne »),
AuditLog.userId, Credit.userId. Un registre comptable ou un journal de
sécurité doit survivre à son sujet : c'est un choix, pas un oubli.
Le reste ne l'était pas, et la conséquence n'est pas théorique.
2.1 Le côté organisation était gardé, le côté store ne l'était pas
src/test/org-delete-reaches-every-orphan.test.ts existe depuis
app-shell/0281. Il dérive du schéma la liste des modèles portant un orgId
nu et échoue tant que le handler DELETE /api/organizations/[orgId] ne les
purge pas ou ne les excuse pas par écrit. C'est exactement la bonne mécanique.
Elle n'avait jamais été posée sur Store. Huit modèles portent un storeId
nu ; deux étaient traités, six ne l'étaient pas :
| Modèle | État avant | Conséquence |
|---|---|---|
BrowserSession | traité (app-shell/0281) | — |
AlgoTrace | traité (app-shell/0281) | — |
OAuthAccessToken | non traité | jeton MCP valide jusqu'à son expiresAt, sur un store supprimé |
OAuthAuthorizationCode | non traité | même famille |
Conversation | non traité | transcription marchand + acheteurs conservée hors rétention |
DevStorePool | retiré par un helper | légitime |
ShopifyCustomerRedaction | conservé | légitime : c'est la preuve de l'effacement |
ShopifyWebhookEvent | conservé | légitime : purgé par son propre cron |
OAuthAccessToken.storeId est, dans le schéma, « the resource (audience) the
token will be bound to ». Toutes les autres portes de révocation du dépôt
tapent sur l'utilisateur : auth/delete-account supprime par userId,
lib/security/ban-enforcement.ts estampille revokedAt par utilisateur.
Supprimer la ressource elle-même ne révoquait rien.
Conversation était couverte côté org — src/app/api/organizations/[orgId]/route.ts
fait tx.conversation.deleteMany({ where: { orgId } }) — mais un store
supprimé dans une organisation survivante passait à travers.
Corrigé dans cette PR, plus le garde dérivé qui empêche le neuvième
modèle orphelin d'arriver en silence. Le reste du plan de
data-platform/0521
— rattrapage des orphelins existants, puis pose des FK — reste une action
opérateur, dans cet ordre : une ADD CONSTRAINT posée avant le nettoyage
échoue en 23503 sur les lignes mêmes que le constat décrit.
2.2 Ce que la liste de 0521 contenait de trop
Deux entrées de ce plan ne devaient pas y figurer, et les laisser aurait coûté :
ShopifyCustomerRedaction— l'item le dit lui-même dans ses contraintes, mais la liste du « Résultat attendu » l'omettait. Supprimer un store ne doit pas effacer la preuve qu'un effacement RGPD a été demandé et drainé.ShopifyWebhookEvent— ledger d'idempotence pur (webhookId,topic,storeId, un horodatage). Aucune donnée personnelle, et le cronprune-audit-logfait déjà vieillir chaque ligne. Une purge ici n'achèterait rien à la rétention et coûterait un scan complet : le modèle indexecreatedAt, jamaisstoreId.
Les deux sont désormais des excuses écrites dans le garde, avec leur motif.
3. Les sources de vérité multiples
3.1 Deux tables pour « ce store est connecté à un fournisseur »
Connector (storeId, provider, accessToken, 27 usages) et
IntegrationConnection (orgId, storeId, provider, accessToken,
109 usages) stockent la même chose.
Le partage s'est fait par accident d'historique, pas par domaine :
| Fournisseur | Table écrite |
|---|---|
| google, meta, klaviyo | Connector |
| shopify | IntegrationConnection |
| figma | IntegrationConnection |
Figma est un connecteur OAuth de store comme Google ou Meta, mais il a été persisté dans la table d'appairage Shopify. Trois conséquences mesurables :
GET /api/connectors?storeID=…litdb.getStoreConnectors()→Connector. Une connexion Figma n'y apparaît jamais, etDELETE /api/connectors/[connectorId]ne peut pas la révoquer.- Le cron
refresh-oauth-tokenslitConnector. Il ne verra jamais un jeton Figma. - Le dépôt a donc dû écrire un rafraîchisseur parallèle,
src/features/connectors/figma-token.ts, dont l'en-tête énonce le problème sans le nommer comme tel : « Nothing in the connector refresh job knows about Figma (it only handles google/meta on the Connector table) ».
Deux tables, deux chiffrements, deux rafraîchisseurs, une seule notion.
Non corrigé ici : déplacer les lignes est une migration de données dans un
dépôt sans prisma/migrations/, dont le guard généré est additif et ne sait
pas déplacer. Item ouvert : integrations/0596.
3.2 Deux tables pour « ce que cet utilisateur a mis de côté »
Watchlist (userId, itemType, itemSlug, table bst_watchlist) et
SavedListing (userId, listingId, FK vers MarketplaceListing).
Watchlist.itemType énumère store | agency | freelancer | app | theme | extension | skill | tool | prompt | mcp | cli : l'intersection avec le
marketplace est presque totale. Deux surfaces distinctes en découlent,
/account/saved et /account/watchlist, et aucune ne montre les lignes de
l'autre. Sauvegarder une app depuis sa fiche marketplace et la sauvegarder
depuis l'API /api/me/watchlist produisent deux états qui ne se voient pas.
SavedListing est le modèle correct des deux : clé composite, FK en cascade,
index couvrant. Watchlist désigne ses cibles par une chaîne libre, donc rien
ne garantit que itemSlug résout.
Non corrigé ici : pilier marketplace, et la fusion est un choix produit
(laquelle des deux surfaces survit). Item ouvert :
marketplace/0597.
4. Modèles inutiles
Un seul, sur 155 : SystemInstall. Zéro lecteur en production, une seule
référence, dans le garde src/test/system-install-is-not-wired.test.ts qui
existe précisément pour constater qu'il n'est pas branché. CLAUDE.md le dit
déjà (« rien ne lit SystemInstall »).
Le laisser est le bon choix tant que le retrait n'est pas décidé : supprimer un modèle du schéma ne supprime pas la table, le guard généré étant additif. Le retrait est une action opérateur, pas un effet de bord de déploiement.
Ce chiffre mérite d'être lu pour ce qu'il est : 154 modèles sur 155 ont un lecteur. Un schéma de cette taille sans code mort est rare.
5. Ce qui n'est PAS un constat
Trois choses ont été vérifiées et tiennent :
- Cohérence des cascades. Les 18 enfants d'
Organizationsont tous enCascade. Les 45 deStoresont enCascadesauf quatreSetNulldélibérés (PlatformActivity,StoreIntelligence,Scan,Prospect) — chacun correspond à une entité qui doit survivre au store.Order.listingIdest enRestrict: on ne supprime pas une annonce qui a des commandes. Aucune contradiction. Organization.planversusSubscription.plan. C'est un cache dénormalisé, il est documenté comme tel sur vingt lignes, écrit par un seul chemin (syncOrgPlanCache()), et aucune porte d'accès ne le lit. Ce n'est pas une double source de vérité, c'est une source de vérité et son index.- Les familles « audit / scan / rapport ».
Scan,StoreReport,AeoAudit,PsiReport,StoreSnapshotse ressemblent de loin. De près elles ont des cycles de vie, des rétentions et des propriétaires différents (Scanest une surface publique partageable,AeoAuditune série temporelle parkind,StoreReportun job déclenché). Les fusionner coûterait plus que la ressemblance ne rapporte.
6. Ce que cette PR livre
| Livrable | Fichier |
|---|---|
| Purge des trois orphelins à la suppression d'un store | src/app/api/stores/[storeId]/route.ts |
| Garde dérivé, symétrique de celui de l'org | src/test/store-delete-reaches-every-orphan.test.ts |
| Cette cartographie | docs/audits/2026-09-11-modele-metier.md |
| Deux items pour ce qui n'est pas corrigé ici | backlog/integrations/0596, backlog/marketplace/0597 |
Le garde a été vérifié dans les deux sens : vert avec le correctif, et rouge sans lui en nommant les trois modèles et la conséquence de sécurité.