Développement Web

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

12 min de lecture

📍 Article principal de la série : Meilisearch 2026 : le guide pratique. Lisez le guide général 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

Mise en pratique en environnement ouest-africain

Trois précisions terrain. Latence DB → Meilisearch : si Postgres et Meilisearch sont sur le même VPS (Hetzner CX23), 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.

Articles connexes

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 ? CX23 (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 étoffer le tableau

Étape 1 : choisir l’architecture de synchronisation

Trois patterns sont possibles pour alimenter un index Meilisearch depuis Postgres : (1) écriture double dans le code applicatif, (2) batch périodique qui rejoue les diffs, (3) capture des changements via triggers SQL et worker dédié. La première est simple mais fragile (oublis dès qu’un job admin écrit hors de l’application). La deuxième est lente. Nous retenons la troisième, robuste et adaptée à un VPS unique à Dakar ou Abidjan.

Concrètement, nous installerons Meilisearch 1.13 et Postgres 17, puis utiliserons Drizzle ORM pour le schéma, des triggers Postgres pour pousser les changements dans une table outbox, et un worker Node 22 LTS qui consomme cette outbox toutes les deux secondes vers Meilisearch.

Étape 2 : installer Meilisearch et créer la clé maîtresse

Sur Ubuntu 24.04, l’installation se fait en deux commandes. La clé maîtresse protège l’instance — ne la commitez jamais.

curl -L https://install.meilisearch.com | sh
./meilisearch --master-key="$(openssl rand -hex 32)" --http-addr 127.0.0.1:7700

La sortie attendue inclut « Server listening on: http://127.0.0.1:7700 ». Pour la production, créez un service systemd qui relance Meilisearch en cas de crash et qui stocke les données sous /var/lib/meilisearch. Comptez environ 1 Go de RAM pour 100 000 documents indexés avec un schéma simple.

Étape 3 : modéliser le schéma Postgres avec Drizzle

Drizzle ORM 0.36 fournit un DSL TypeScript proche du SQL, sans la surcouche d’un Prisma. Définissez la table métier et la table outbox côte à côte.

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

export const produits = pgTable("produits", {
  id: serial("id").primaryKey(),
  titre: text("titre").notNull(),
  description: text("description"),
  prix_fcfa: serial("prix_fcfa")
});

export const outbox = pgTable("outbox", {
  id: serial("id").primaryKey(),
  table_name: text("table_name").notNull(),
  pk: text("pk").notNull(),
  op: text("op").notNull(),
  payload: jsonb("payload"),
  created_at: timestamp("created_at").defaultNow()
});

Lancez ensuite npx drizzle-kit generate puis npx drizzle-kit migrate. Vérifiez avec \dt dans psql que les deux tables existent et que les contraintes sont posées.

Étape 4 : poser les triggers de capture des changements

Un trigger AFTER INSERT/UPDATE/DELETE alimente l’outbox à chaque mutation, qu’elle vienne de l’application Node ou d’un script admin. C’est ce qui rend l’architecture robuste.

CREATE OR REPLACE FUNCTION push_outbox() RETURNS trigger AS $$
BEGIN
  INSERT INTO outbox(table_name, pk, op, payload)
  VALUES (TG_TABLE_NAME, COALESCE(NEW.id, OLD.id)::text, TG_OP,
          CASE WHEN TG_OP = 'DELETE' THEN row_to_json(OLD) ELSE row_to_json(NEW) END);
  RETURN COALESCE(NEW, OLD);
END; $$ LANGUAGE plpgsql;

CREATE TRIGGER produits_outbox AFTER INSERT OR UPDATE OR DELETE
ON produits FOR EACH ROW EXECUTE FUNCTION push_outbox();

Testez immédiatement avec un INSERT puis un SELECT sur outbox : vous devez voir une ligne avec op = ‘INSERT’ et le payload JSON complet. Si la table outbox reste vide, vérifiez que le trigger est bien attaché via \d produits.

Étape 5 : écrire le worker Node 22 qui consomme l’outbox

Le worker boucle toutes les deux secondes, extrait jusqu’à 500 lignes de l’outbox dans une transaction, pousse vers Meilisearch, puis supprime les lignes consommées. En cas d’erreur réseau, la transaction est rollback et la boucle reprendra au prochain tour.

import { MeiliSearch } from "meilisearch";
import { db } from "./db.js";

const meili = new MeiliSearch({ host: "http://127.0.0.1:7700", apiKey: process.env.MEILI_KEY });
const index = meili.index("produits");

setInterval(async () => {
  await db.transaction(async (tx) => {
    const lots = await tx.execute("SELECT * FROM outbox ORDER BY id LIMIT 500 FOR UPDATE SKIP LOCKED");
    if (!lots.length) return;
    const upserts = lots.filter(l => l.op !== "DELETE").map(l => l.payload);
    const deletes = lots.filter(l => l.op === "DELETE").map(l => l.pk);
    if (upserts.length) await index.addDocuments(upserts);
    if (deletes.length) await index.deleteDocuments(deletes);
    await tx.execute("DELETE FROM outbox WHERE id = ANY($1)", [lots.map(l => l.id)]);
  });
}, 2000);

Le verrou FOR UPDATE SKIP LOCKED permet de faire tourner deux workers en parallèle si le débit l’exige, sans qu’ils se marchent dessus. Pour la majorité des PME ouest-africaines, un seul worker suffit largement.

Étape 6 : configurer les attributs Meilisearch (recherche, filtre, tri)

Par défaut Meilisearch indexe tout, mais sans configuration explicite vous ne pourrez ni filtrer ni trier. Lancez ces requêtes une fois pour figer la configuration de l’index.

curl -X PATCH "http://127.0.0.1:7700/indexes/produits/settings" \
  -H "Authorization: Bearer $MEILI_KEY" -H "Content-Type: application/json" \
  -d '{"searchableAttributes":["titre","description"],
       "filterableAttributes":["prix_fcfa"],
       "sortableAttributes":["prix_fcfa"]}'

Cette configuration permettra ensuite des requêtes du type filter=prix_fcfa < 50000&sort=prix_fcfa:asc, idéal pour un catalogue e-commerce qui affiche les produits sous 50 000 FCFA triés du moins cher au plus cher.

Étape 7 : superviser la latence et la profondeur de l’outbox

Exposez deux métriques Prometheus dans le worker : la profondeur courante de l’outbox (gauge) et le délai moyen entre l’insertion d’une ligne dans outbox et sa consommation (histogram). Une profondeur qui monte sans redescendre signale un worker bloqué ou une instance Meilisearch saturée.

SELECT count(*), max(now() - created_at) FROM outbox;

Sur un VPS Contabo 4 vCPU à Francfort utilisé depuis Dakar, comptez moins de 200 ms de latence p95 entre l’écriture Postgres et la disponibilité dans Meilisearch. Au-delà, profilez le worker.

Étape 8 : industrialiser avec systemd et CI

Empaquetez le worker comme service systemd avec redémarrage automatique. Côté CI, ajoutez un test d’intégration qui démarre Postgres + Meilisearch via Docker Compose, insère un produit, attend 3 secondes et vérifie qu’il est searchable. Pour étoffer le tableau, lisez nos guides Postgres pgvector pour la recherche sémantique et Drizzle ORM avec Postgres en TypeScript.

[Unit]
Description=Meili Sync Worker
[Service]
ExecStart=/usr/bin/node /opt/sync/worker.js
Restart=always
[Install]
WantedBy=multi-user.target

Activez avec systemctl enable --now meili-sync. Vérifiez les logs via journalctl -u meili-sync -f et confirmez que vous voyez « synced N docs » toutes les deux secondes en charge normale.

Partager