Runbooks & opérationsMaintenance des index — l'action que le déploiement ne peut pas faire

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À supprimerDé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:deploy pour cette opération. Il lance prisma db push --accept-data-loss, qui appliquerait tout le delta destructif entre le schéma et la base, pas seulement ces onze index. Les DROP INDEX ci-dessus sont explicites et limités ; l'outil ne l'est pas.
  • Pas de suppression sans mesure préalable si on hésite. 0049 est structurel, pas mesuré : aucun EXPLAIN n'a été lancé, aucune statistique pg_stat_user_indexes consulté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 sur Message et AuditLog.

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 :

ObjetDétail
TablesStudioDrop (porte encore des shareToken), Prospect, ProspectEvent
Colonne de StudioAssetdropId, son index et sa clé étrangère vers StudioDrop
Colonnes de StorecreativeCadence, creativeLastDropAt, onboardingScore, onboardingScoredAt, assetsReady, claimsValidated, capacityValidated, marginViable, brandSystemReady, cadenceDecided, clientOwnerUserId, creativeOwnerUserId, dodAgreed, goProduction, goProductionAt, goProductionByUserId, oneShotFoundation, contentFactory, priorityNeeds, priorityProducts, preferredCta, platforms
EnumsStudioDropStatus, 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