Maintenance des index — l'action que le déploiement ne peut pas faire
Onze index redondants attendent une suppression. Ce document est la procédure, et il existe parce qu'aucun chemin automatique de cette plateforme ne peut la porter. La même raison vaut pour les tables et colonnes…
Onze index redondants attendent une suppression. Ce document est la procédure, et il existe parce qu'aucun chemin automatique de cette plateforme ne peut la porter. La même raison vaut pour les tables et colonnes orphelines du plan C : leur procédure est plus bas.
Pourquoi ce n'est pas un déploiement
Le schema guard est additif par conception
(src/services/database/schema-guard.ts) :
il sait créer une table, ajouter une colonne, une valeur d'enum, un index.
Il ne supprime jamais rien. Et sur ce projet le build ne pousse même pas le
schéma : scripts/vercel-build.mjs ne lance prisma db push que si une URL
de base est présente dans l'environnement de BUILD, ce que l'intégration
Vercel + Neon ne fait pas (le log dit « skipping prisma db push »). Ce sont
les ajouts appliqués au cold-start qui synchronisent la base. Quand ce push
tourne quand même (dev, autre hébergeur), c'est sans
--accept-data-loss, donc un delta destructif est refusé plutôt
qu'appliqué. Cette page a affirmé que le build lançait ce push : c'était
faux, et c'est la lecture que CLAUDE.md prend un paragraphe à interdire.
Ce n'est pas une lacune : c'est ce qui garantit qu'un déploiement ne peut
pas détruire de données. La contrepartie est qu'un DROP INDEX est une
action opérateur délibérée, jamais un effet de bord d'un merge.
La conséquence à ne pas manquer : le guard ignore un index qui existe
en base et pas dans le schéma. Il ne le signale pas, il ne le supprime pas.
Retirer un index de prisma/schema.prisma sans le supprimer en base crée
donc une divergence que rien ne rattrape et que rien ne rapporte.
D'où l'ordre imposé plus bas : la base d'abord, le schéma dans le même geste, jamais l'inverse.
Ce qui est en attente
pnpm db:indexes liste l'état réel. Onze index sont entièrement servis par
un autre index dont ils sont un préfixe : ils ne répondent à aucune requête
que le composite ne répond pas, et ils sont réécrits à chaque INSERT et
UPDATE.
| Modèle | À supprimer | Déjà servi par |
|---|---|---|
Message | [conversationId] | [conversationId, createdAt] |
AuditLog | [orgId] | [orgId, createdAt] |
Credit | [orgId] | [orgId, expiresAt] |
DevStorePool | [status] | [status, transferRequestedAt] |
MarketplaceListing | [sellerId] | [sellerId, status, publishedAt] |
Offer | [listingId, status] | [listingId, status, createdAt] |
DealThread | [sellerId] | [sellerId, status, createdAt] |
DealThread | [buyerId] | [buyerId, status, createdAt] |
Order | [buyerId] | [buyerId, status, createdAt] |
Order | [listingId] | [listingId, status] |
ProductPerformanceDaily | [storeId, day] | [storeId, day, revenueCents] |
Message et AuditLog sont les deux qui comptent : elles sont en append
quasi continu, donc c'est là que le coût d'écriture tombe réellement.
Sur ProductPerformanceDaily, la raison n'est pas celle qu'on croit.
Les deux index sont [storeId, day(sort: Desc)] et
[storeId, day, revenueCents(sort: Desc)] : c'est day, la deuxième
colonne, qui est Desc d'un côté et ASC de l'autre. Le standalone reste
redondant — avec storeId fixé par égalité, Postgres sert
ORDER BY day DESC par un parcours arrière du composite — mais pas pour la
raison « tête ASC des deux côtés » que data-platform/0049 avait écrite.
La procédure
1. Confirmer les noms contre la base
Les noms ci-dessous sont ceux que Prisma génère. Ils ne sont pas une observation. Avant de supprimer quoi que ce soit :
SELECT indexname, tablename, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY tablename, indexname;
Vérifier que chaque nom visé existe, et qu'il correspond bien aux colonnes attendues. Un nom qui ne ressort pas est un index déjà supprimé, ou un index dont le nom a été fixé à la main : ne pas deviner.
2. Vérifier qu'aucun n'est adossé à une contrainte
Un index qui soutient un UNIQUE ou une PRIMARY KEY ne se supprime pas
par DROP INDEX — et ne doit pas l'être.
SELECT conname, conrelid::regclass AS table_name, contype
FROM pg_constraint
WHERE conindid IN (
SELECT indexrelid FROM pg_index
WHERE indexrelid::regclass::text = ANY (ARRAY[
'Message_conversationId_idx',
'AuditLog_orgId_idx',
'Credit_orgId_idx',
'DevStorePool_status_idx',
'MarketplaceListing_sellerId_idx',
'Offer_listingId_status_idx',
'DealThread_sellerId_idx',
'DealThread_buyerId_idx',
'Order_buyerId_idx',
'Order_listingId_idx',
'ProductPerformanceDaily_storeId_day_idx'
])
);
Zéro ligne attendue. Les onze ont été vérifiés côté schéma — les @@unique
de Offer et ProductPerformanceDaily portent sur d'autres colonnes — mais
la base est l'autorité, pas le schéma.
3. Supprimer, un par un
DROP INDEX CONCURRENTLY IF EXISTS "Message_conversationId_idx";
DROP INDEX CONCURRENTLY IF EXISTS "AuditLog_orgId_idx";
DROP INDEX CONCURRENTLY IF EXISTS "Credit_orgId_idx";
DROP INDEX CONCURRENTLY IF EXISTS "DevStorePool_status_idx";
DROP INDEX CONCURRENTLY IF EXISTS "MarketplaceListing_sellerId_idx";
DROP INDEX CONCURRENTLY IF EXISTS "Offer_listingId_status_idx";
DROP INDEX CONCURRENTLY IF EXISTS "DealThread_sellerId_idx";
DROP INDEX CONCURRENTLY IF EXISTS "DealThread_buyerId_idx";
DROP INDEX CONCURRENTLY IF EXISTS "Order_buyerId_idx";
DROP INDEX CONCURRENTLY IF EXISTS "Order_listingId_idx";
DROP INDEX CONCURRENTLY IF EXISTS "ProductPerformanceDaily_storeId_day_idx";
CONCURRENTLY ne prend pas de verrou exclusif sur la table : la production
continue de lire et d'écrire pendant l'opération. En contrepartie, il ne
peut pas tourner dans une transaction — donc pas de BEGIN, et une
commande à la fois.
Si l'un échoue en laissant un index INVALID (visible dans pg_index avec
indisvalid = false), le relancer : DROP INDEX CONCURRENTLY est
réexécutable.
4. Retirer du schéma, dans le même geste
Supprimer les @@index correspondants de prisma/schema.prisma, supprimer
leurs lignes de ACCEPTED dans
scripts/check-redundant-indexes.mjs,
puis :
pnpm db:guard # régénère le schema guard
pnpm db:indexes # doit passer, avec moins de paires
pnpm db:guard:check
Ouvrir une PR avec ces trois changements ensemble. pnpm db:indexes échoue
si une ligne de ACCEPTED ne correspond plus à un index du schéma : c'est
volontaire, c'est ce qui force cette étape à ne pas être oubliée.
5. Ce qu'on ne fait pas
- Pas de
pnpm db:deploypour cette opération. Il lanceprisma db push --accept-data-loss, qui appliquerait tout le delta destructif entre le schéma et la base, pas seulement ces onze index. LesDROP INDEXci-dessus sont explicites et limités ; l'outil ne l'est pas. - Pas de suppression sans mesure préalable si on hésite.
0049est structurel, pas mesuré : aucunEXPLAINn'a été lancé, aucune statistiquepg_stat_user_indexesconsultée. Le raisonnement (un préfixe est servi par son composite) est solide, mais si on veut prioriser plutôt qu'exécuter, mesurer d'abord le coût d'écriture surMessageetAuditLog.
Le garde qui empêche le douzième
pnpm db:indexes tourne en CI et refuse toute nouvelle paire redondante.
Les onze existantes sont sur une liste d'acceptation qui ne peut que
rétrécir : un index qui en sort sans être réellement supprimé fait échouer
le run, dans l'autre sens.
Ce garde ne se connecte à aucune base. Il lit prisma/schema.prisma, donc
un index présent en base et absent du schéma lui est invisible — c'est
exactement l'angle mort décrit en haut de page, et la raison pour laquelle
l'étape 1 lit pg_indexes au lieu de faire confiance à cette liste.
Il ne dit rien non plus de l'usage : un index composite que personne
n'interroge est du gaspillage que ce garde qualifiera volontiers de
« couvrant ». Pour cette question-là, c'est pg_stat_user_indexes.
Les tables orphelines du plan C
Le plan C (agence) a quitté le schéma le 2026-09-26
(ADR 0043). Même
raisonnement qu'en haut de page : le guard est additif, donc retirer un
modèle laisse sa table, et rien ne la supprime ni ne la signale. Suivi :
data-platform/3073.
Ce qui reste en base, d'après le diff du schéma de ce retrait :
| Objet | Détail |
|---|---|
| Tables | StudioDrop (porte encore des shareToken), Prospect, ProspectEvent |
Colonne de StudioAsset | dropId, son index et sa clé étrangère vers StudioDrop |
Colonnes de Store | creativeCadence, creativeLastDropAt, onboardingScore, onboardingScoredAt, assetsReady, claimsValidated, capacityValidated, marginViable, brandSystemReady, cadenceDecided, clientOwnerUserId, creativeOwnerUserId, dodAgreed, goProduction, goProductionAt, goProductionByUserId, oneShotFoundation, contentFactory, priorityNeeds, priorityProducts, preferredCta, platforms |
| Enums | StudioDropStatus, CreativeCadence, ProspectStatus, ProspectChannel, ProspectPriority |
Tant qu'ils existent, un prisma db push lancé sans --accept-data-loss
(dev, ou un build qui aurait une URL de base) voit ce delta destructif et
refuse probablement de s'exécuter. C'est déduit du comportement documenté
de Prisma, pas observé sur la base.
1. Sauvegarder
Une branche Neon de la production (instantané copy-on-write), nommée
d'après la date. C'est la seule restauration possible : les
shareToken et les fiches prospects n'existent nulle part ailleurs.
2. Confirmer l'état réel
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('StudioDrop', 'Prospect', 'ProspectEvent');
SELECT table_name, column_name FROM information_schema.columns
WHERE table_schema = 'public'
AND ((table_name = 'StudioAsset' AND column_name = 'dropId')
OR (table_name = 'Store' AND column_name IN (
'creativeCadence', 'creativeLastDropAt', 'onboardingScore',
'onboardingScoredAt', 'assetsReady', 'claimsValidated',
'capacityValidated', 'marginViable', 'brandSystemReady',
'cadenceDecided', 'clientOwnerUserId', 'creativeOwnerUserId',
'dodAgreed', 'goProduction', 'goProductionAt', 'goProductionByUserId',
'oneShotFoundation', 'contentFactory', 'priorityNeeds',
'priorityProducts', 'preferredCta', 'platforms')));
Puis vérifier qu'aucun code ne les lit encore : grep -rn de chaque nom
dans src/ doit ne rendre que des commentaires. Et le diff hors ligne,
qui liste tout ce qui diverge sans rien écrire :
npx prisma migrate diff --from-url "$DATABASE_URL" \
--to-schema-datamodel prisma/schema.prisma --script
Le script rendu doit contenir ces objets et rien d'autre de destructif. S'il contient autre chose, s'arrêter : c'est une seconde divergence, qui a son propre item.
3. Supprimer, dans une transaction
BEGIN;
ALTER TABLE "StudioAsset" DROP COLUMN IF EXISTS "dropId";
DROP TABLE IF EXISTS "ProspectEvent";
DROP TABLE IF EXISTS "Prospect";
DROP TABLE IF EXISTS "StudioDrop";
ALTER TABLE "Store"
DROP COLUMN IF EXISTS "creativeCadence",
DROP COLUMN IF EXISTS "creativeLastDropAt",
DROP COLUMN IF EXISTS "onboardingScore",
DROP COLUMN IF EXISTS "onboardingScoredAt",
DROP COLUMN IF EXISTS "assetsReady",
DROP COLUMN IF EXISTS "claimsValidated",
DROP COLUMN IF EXISTS "capacityValidated",
DROP COLUMN IF EXISTS "marginViable",
DROP COLUMN IF EXISTS "brandSystemReady",
DROP COLUMN IF EXISTS "cadenceDecided",
DROP COLUMN IF EXISTS "clientOwnerUserId",
DROP COLUMN IF EXISTS "creativeOwnerUserId",
DROP COLUMN IF EXISTS "dodAgreed",
DROP COLUMN IF EXISTS "goProduction",
DROP COLUMN IF EXISTS "goProductionAt",
DROP COLUMN IF EXISTS "goProductionByUserId",
DROP COLUMN IF EXISTS "oneShotFoundation",
DROP COLUMN IF EXISTS "contentFactory",
DROP COLUMN IF EXISTS "priorityNeeds",
DROP COLUMN IF EXISTS "priorityProducts",
DROP COLUMN IF EXISTS "preferredCta",
DROP COLUMN IF EXISTS "platforms";
DROP TYPE IF EXISTS "StudioDropStatus";
DROP TYPE IF EXISTS "CreativeCadence";
DROP TYPE IF EXISTS "ProspectStatus";
DROP TYPE IF EXISTS "ProspectChannel";
DROP TYPE IF EXISTS "ProspectPriority";
COMMIT;
L'ordre compte : la colonne dropId porte la clé étrangère vers
StudioDrop, et ProspectEvent celle vers Prospect. Un DROP TYPE
échoue tant qu'une colonne l'utilise, donc les types passent en dernier.
Une transaction, contrairement aux index plus haut : aucun CONCURRENTLY
ici, et un échec au milieu doit tout annuler.
4. Vérifier
Relancer le prisma migrate diff de l'étape 2 : il ne doit plus rien
lister de ces objets. Rien à changer dans le schéma ni dans le guard : ils
en sont déjà sortis.
Pas de pnpm db:deploy ici non plus, pour la même raison que plus
haut : il appliquerait tout le delta destructif du moment, pas seulement
ces objets. L'ADR 0043 le nomme comme moyen ; la liste explicite ci-dessus
fait la même chose en disant exactement quoi.
Voir aussi
data-platform/0049— l'item, sa moitié 1 livrée (quatre index de clé étrangère ajoutés) et sa moitié 2 en attentedocs/audits/2026-08-25-data-platform.md— constat 3, l'origineCLAUDE.md§ « Schema sync en deploy » — pourquoi le guard est additif