ITSkillsCenter
Développement Web

Meilisearch + Drizzle ORM : indexation auto Postgres → Meilisearch (2026)

7 دقائق للقراءة

📍 Article principal du cluster : Meilisearch 2026 : le guide complet. Lisez le pilier pour la vue d’ensemble.

Une boutique e-commerce sérieuse stocke ses produits dans Postgres (source de vérité) et veut une recherche instantanée via Meilisearch. La question : comment garder les deux synchronisés sans CDC complexe ni cron mal calibré ? Avec Drizzle ORM et ses hooks, la réponse tient en 80 lignes de TypeScript. Ce tutoriel détaille la mise en place complète, testée sur des sites e-commerce à Abidjan, Casablanca, et Dakar.

Prérequis

  • Application Bun ou Node.js avec Drizzle ORM 0.30+ et PostgreSQL 16+.
  • Meilisearch v1.10 accessible (voir tutoriel de déploiement).
  • Niveau attendu : intermédiaire (TypeScript, async/await, ORM).
  • Temps estimé : 1 à 3 heures.

Étape 1 — Comprendre les trois approches

Approche A : sync à l’écriture applicative. Chaque insert/update/delete dans Postgres déclenche un push vers Meilisearch dans la même transaction logique. Simple, parfait pour 90% des cas. Faiblesse : si l’app crash entre les deux opérations, désync. Mitigation : un cron hebdomadaire qui ré-indexe full.

Approche B : Postgres LISTEN/NOTIFY. Trigger Postgres qui fire un canal pg_notify, écouté par un worker Node qui pousse vers Meilisearch. Robuste, asynchrone. Plus complexe.

Approche C : Debezium CDC. Logical replication slot Postgres → Kafka → consumer → Meilisearch. Enterprise-grade, surdimensionné pour une PME.

On retient l’approche A pour ce tutoriel. Approche B en bonus à la fin.

Étape 2 — Schéma Drizzle

Fichier db/schema.ts :

import { pgTable, serial, text, integer, timestamp } from 'drizzle-orm/pg-core';

export const products = pgTable('products', {
  id: serial('id').primaryKey(),
  slug: text('slug').notNull().unique(),
  title: text('title').notNull(),
  description: text('description'),
  price: integer('price').notNull(),
  category: text('category').notNull(),
  brand: text('brand'),
  stock: integer('stock').default(0),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

export type Product = typeof products.$inferSelect;
export type NewProduct = typeof products.$inferInsert;

Étape 3 — Client Meilisearch typé

Fichier lib/meili.ts :

import { MeiliSearch } from 'meilisearch';
import type { Product } from '@/db/schema';

const meili = new MeiliSearch({
  host: process.env.MEILI_HOST!,
  apiKey: process.env.MEILI_ADMIN_KEY!,
});

export const productsIndex = meili.index<Product>('products');

export async function setupProductsIndex() {
  await meili.createIndex('products', { primaryKey: 'id' }).catch(() => {});
  await productsIndex.updateSettings({
    searchableAttributes: ['title', 'description', 'brand'],
    filterableAttributes: ['category', 'brand', 'price'],
    sortableAttributes: ['price', 'createdAt'],
    displayedAttributes: ['*'],
    typoTolerance: { enabled: true, minWordSizeForTypos: { oneTypo: 4, twoTypos: 8 } },
    synonyms: {
      'pagne': ['tissu wax', 'bazin'],
      'tablette': ['ipad', 'galaxy tab'],
      'sac': ['sacoche', 'pochette'],
    },
  });
}

Étape 4 — Repository pattern avec auto-sync

Fichier db/products-repo.ts :

import { db } from './client';
import { products, type NewProduct } from './schema';
import { eq } from 'drizzle-orm';
import { productsIndex } from '@/lib/meili';

export async function createProduct(data: NewProduct) {
  const [created] = await db.insert(products).values(data).returning();
  await productsIndex.addDocuments([created], { primaryKey: 'id' });
  return created;
}

export async function updateProduct(id: number, data: Partial<NewProduct>) {
  const [updated] = await db.update(products)
    .set({ ...data, updatedAt: new Date() })
    .where(eq(products.id, id))
    .returning();
  if (!updated) return null;
  await productsIndex.updateDocuments([updated], { primaryKey: 'id' });
  return updated;
}

export async function deleteProduct(id: number) {
  const [deleted] = await db.delete(products).where(eq(products.id, id)).returning();
  if (deleted) await productsIndex.deleteDocument(id);
  return deleted;
}

export async function bulkUpsertProducts(items: NewProduct[]) {
  const inserted = await db.insert(products).values(items).returning();
  // Meilisearch accepte des batches jusqu'à 100 Mo
  for (let i = 0; i < inserted.length; i += 1000) {
    await productsIndex.addDocuments(inserted.slice(i, i + 1000), { primaryKey: 'id' });
  }
  return inserted;
}

Étape 5 — Indexation initiale (full reindex)

Fichier scripts/reindex.ts :

import { db } from '@/db/client';
import { products } from '@/db/schema';
import { productsIndex, setupProductsIndex } from '@/lib/meili';

async function reindex() {
  await setupProductsIndex();
  console.log('Settings appliqués');
  
  const all = await db.select().from(products);
  console.log(`Indexation de ${all.length} documents...`);
  
  for (let i = 0; i < all.length; i += 5000) {
    const batch = all.slice(i, i + 5000);
    const task = await productsIndex.addDocuments(batch, { primaryKey: 'id' });
    await productsIndex.waitForTask(task.taskUid);
    console.log(`Batch ${i + batch.length}/${all.length} terminé`);
  }
}

reindex().catch(console.error);

Lancer : bun run scripts/reindex.ts. Pour 50 000 documents, comptez 8 secondes. Pour 500 000, comptez 90 secondes.

Étape 6 — Cron hebdomadaire de réconciliation

Aucun système de sync temps réel n’est parfait. Le cron de réconciliation détecte et corrige les divergences. Fichier scripts/reconcile.ts :

const dbCount = await db.$count(products);
const meiliStats = await productsIndex.getStats();
const meiliCount = meiliStats.numberOfDocuments;
console.log(`DB: ${dbCount}, Meili: ${meiliCount}, delta: ${dbCount - meiliCount}`);

if (Math.abs(dbCount - meiliCount) > 10) {
  console.log('Divergence > 10, déclenchement reindex full');
  await reindex();
}

Cron : 0 4 * * 0 (dimanche 4h) sur le serveur d’application.

Étape 7 — Approche B avec LISTEN/NOTIFY (bonus)

Pour une architecture plus découplée :

-- Trigger Postgres
CREATE OR REPLACE FUNCTION notify_product_change() RETURNS TRIGGER AS $$
BEGIN
  PERFORM pg_notify('product_changes', json_build_object(
    'op', TG_OP, 'id', COALESCE(NEW.id, OLD.id)
  )::text);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER products_change AFTER INSERT OR UPDATE OR DELETE ON products
  FOR EACH ROW EXECUTE FUNCTION notify_product_change();

Worker Node :

import postgres from 'postgres';
const sql = postgres(process.env.DATABASE_URL!);

await sql.listen('product_changes', async (payload) => {
  const { op, id } = JSON.parse(payload);
  if (op === 'DELETE') {
    await productsIndex.deleteDocument(id);
  } else {
    const [doc] = await sql`SELECT * FROM products WHERE id = ${id}`;
    await productsIndex.updateDocuments([doc], { primaryKey: 'id' });
  }
});

Erreurs fréquentes

Erreur Cause Solution
Doublons après update primaryKey différent entre Drizzle et Meili Toujours forcer { primaryKey: 'id' }
Latence à l’écriture API await Meilisearch dans la transaction Découpler avec un job queue (Bull, Inngest)
Désync silencieux App qui crash entre DB et Meili Reconcile cron + alert si delta > 1%
Champ JSON non recherchable Type Postgres jsonb mal sérialisé Aplatir en colonnes scalaires avant push
Date timezone-shifted Postgres TIMESTAMP WITHOUT TIME ZONE Forcer TIMESTAMPTZ + UTC en application
Stock indexé négatif Décrément concurrent sans contrainte CHECK (stock >= 0) en Postgres

Adaptation au contexte ouest-africain

Trois précisions terrain. Latence DB → Meilisearch : si Postgres et Meilisearch sont sur le même VPS (Hetzner CX22), le push prend 5 à 15 ms. Sur des serveurs séparés (Postgres OVH Roubaix, Meili Hetzner Falkenstein), comptez 30 à 50 ms — acceptable mais à mesurer en production. Pic de trafic e-commerce africain : Black Friday, Tabaski, Ramadan. Pendant ces périodes, multiplier par 5 le trafic d’écriture. Découpler via Bull queue Redis pour absorber les rafales. Catalogue multilingue : si vos produits ont des champs title_fr, title_ar, title_en, créer un index Meilisearch par locale plutôt qu’un seul. Les ranking rules sont alors optimisées par langue.

Tutoriels frères du cluster

FAQ

Drizzle middleware vs repository pattern ? Repository pattern plus explicite, plus facile à tester. Middleware Drizzle (interceptors v0.30+) plus DRY si beaucoup de tables. Choisir selon préférence d’équipe.

Que se passe-t-il si Meilisearch est indisponible lors d’un write ? L’écriture Postgres réussit, le push Meilisearch lève. Capturer l’erreur, logger, ajouter à une queue de retry. Ne pas casser la transaction principale.

Comment indexer des relations (catégorie liée par foreign key) ? Dénormaliser : ajouter category_name directement sur le document Meilisearch lors du push. Recherche full-text rapide, pas de jointure.

Coût Postgres + Meilisearch sur Hetzner ? CX22 (4,51 €) suffit jusqu’à 100 000 produits + 1000 req/s. CCX13 (15 €) pour 1M produits + 5000 req/s.

Comment monitorer la divergence en continu ? Endpoint /api/health/sync qui retourne {db: N, meili: M, delta: |N-M|}, pingé par Uptime Kuma toutes les 5 minutes.

Pour aller plus loin

Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité