📍 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
- 🔝 Retour au pilier : Guide complet Meilisearch 2026
- Documentation Drizzle : orm.drizzle.team/docs
- Documentation Meilisearch JS SDK : github.com/meilisearch/meilisearch-js