ITSkillsCenter
تطوير الويب

Meilisearch + Drizzle ORM: فهرسة تلقائية Postgres → Meilisearch (2026)

3 min de lecture

📍 المقالة الرئيسية: Meilisearch 2026: الدليل الكامل.

متجر تجارة إلكترونية جدي يخزن منتجاته في Postgres (مصدر الحقيقة) ويريد بحثاً فورياً عبر Meilisearch. السؤال: كيف نحافظ على المزامنة بين الاثنين دون CDC معقدة ولا cron مضبوط بشكل سيئ؟ مع Drizzle ORM وhooks الخاصة به، الإجابة تكمن في 80 سطراً من TypeScript. هذا الدرس يفصِّل التركيب الكامل، المختبر على مواقع تجارة إلكترونية في أبيدجان والدار البيضاء وداكار.

المتطلبات

قبل البدء، تأكد من تطبيق Bun أو Node.js مع Drizzle ORM 0.30+ و PostgreSQL 16+. يجب أن يكون Meilisearch v1.10 متاحاً. المستوى المطلوب: متوسط (TypeScript، async/await، ORM). الوقت المقدر: 1 إلى 3 ساعات.

الخطوة 1 — فهم المناهج الثلاثة

هناك ثلاث استراتيجيات للحفاظ على Postgres و Meilisearch متزامنين. المنهج A: المزامنة عند الكتابة التطبيقية. كل insert/update/delete في Postgres يطلق push نحو Meilisearch في نفس الصفقة المنطقية. بسيط، مثالي لـ 90% من الحالات. ضعف: إذا تعطل التطبيق بين العمليتين، عدم تزامن. المنهج B: Postgres LISTEN/NOTIFY. Trigger Postgres يطلق قناة pg_notify، يستمع إليها worker Node يدفع نحو Meilisearch. قوي، غير متزامن. أكثر تعقيداً. المنهج C: Debezium CDC. Logical replication slot Postgres → Kafka → consumer → Meilisearch. مستوى مؤسسي، مفرط لشركة صغيرة. نحتفظ بالمنهج A لهذا الدرس.

الخطوة 2 — مخطط Drizzle

نُعرّف أولاً مخطط جدول المنتجات في Drizzle. الأنواع المُولَّدة تُستخدم لاحقاً لتأمين الأنواع في Meilisearch.

// 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;

الخطوة 3 — عميل Meilisearch مع الأنواع

نُنشئ عميل Meilisearch مكتوب بالأنواع، ثم نُعرِّف دالة لتكوين الفهرس مع المرادفات الإفريقية (pagne = tissu wax = bazin، إلخ).

// 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'],
    typoTolerance: { enabled: true },
    synonyms: {
      'pagne': ['tissu wax', 'bazin'],
      'tablette': ['ipad', 'galaxy tab'],
      'sac': ['sacoche', 'pochette'],
    },
  });
}

الخطوة 4 — Repository pattern مع auto-sync

نُنشئ Repository يُغلِّف عمليات قاعدة البيانات. كل دالة كتابة تكتب في Postgres ثم تدفع نحو Meilisearch تلقائياً. هذه الطريقة بسيطة وموثوقة لـ 90% من الحالات.

// 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) 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;
}

الخطوة 5 — الفهرسة الأولية الكاملة

سكربت لإعادة فهرسة كاملة عند الإطلاق الأولي أو في حالة عدم تزامن كبير. يُعالج الوثائق في batches 5000 لتجنب OOM.

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

async function reindex() {
  await setupProductsIndex();
  const all = await db.select().from(products);
  console.log(`فهرسة ${all.length} وثيقة...`);
  
  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} مكتمل`);
  }
}

reindex().catch(console.error);

التشغيل: bun run scripts/reindex.ts. لـ 50,000 وثيقة، احسب 8 ثوانٍ. لـ 500,000، احسب 90 ثانية.

الخطوة 6 — Cron أسبوعي للمصالحة

لا يوجد نظام مزامنة في الوقت الفعلي مثالي. cron المصالحة يكشف ويصحح الانحرافات. إذا كانت الفجوة بين عدد سجلات DB و Meilisearch أكبر من 10، يُطلق reindex كامل.

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

if (Math.abs(dbCount - meiliStats.numberOfDocuments) > 10) {
  console.log('انحراف > 10، إطلاق reindex كامل');
  await reindex();
}

الخطوة 7 — المنهج B مع LISTEN/NOTIFY

لمعمارية أكثر استقلالية، يمكن استخدام Postgres triggers + worker مستقل. هذا أكثر تعقيداً لكنه يعزل تطبيق المستخدم من الكتابة في Meilisearch.

-- 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();

الأخطاء الشائعة

الخطأ السبب الحل
مكررات بعد update primaryKey مختلف بين Drizzle و Meili دائماً افرض { primaryKey: ‘id’ }
زمن استجابة كتابة API await Meilisearch في الصفقة افصل بـ job queue (Bull، Inngest)
عدم تزامن صامت تطبيق يتعطل بين DB و Meili Reconcile cron + alert إذا كان الانحراف > 1%
حقل JSON غير قابل للبحث نوع Postgres jsonb مُسلسل بشكل سيئ سطح في أعمدة scalaires قبل push

التكيف مع السياق المغاربي وغرب إفريقيا

ثلاث توضيحات. زمن استجابة DB → Meilisearch: إذا كان Postgres و Meilisearch على نفس VPS، الـ push يأخذ 5 إلى 15 ميلي ثانية. على خوادم منفصلة، احسب 30 إلى 50 ميلي ثانية. قمة حركة التجارة الإلكترونية الإفريقية: Black Friday، Tabaski، Ramadan. خلال هذه الفترات، اضرب في 5 حركة الكتابة. افصل عبر Bull queue Redis لاستيعاب الرشقات. كتالوج متعدد اللغات: إذا كانت منتجاتك تحتوي على حقول title_fr، title_ar، title_en، أنشئ فهرس Meilisearch لكل locale بدلاً من واحد.

دروس الإخوة

الأسئلة المتكررة

Drizzle middleware vs repository pattern؟ Repository pattern أكثر صراحة، أسهل للاختبار. Middleware Drizzle (interceptors v0.30+) أكثر DRY إذا كان لديك جداول كثيرة.

ماذا يحدث إذا كان Meilisearch غير متاح أثناء الكتابة؟ الكتابة Postgres تنجح، الـ push Meilisearch يرفع. التقط الخطأ، سجل، أضف إلى queue retry.

كيفية فهرسة العلاقات (الفئة المرتبطة بالمفتاح الخارجي)؟ denormalize: أضف category_name مباشرة على وثيقة Meilisearch عند push.

للاستزادة

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é