الكتابات/tutorial/2026/05
Tutorial13 مايو 2026·28 دقيقة

المزامنة الفورية مع ElectricSQL و Next.js 15

تعلّم كيفية بناء تطبيقات تعمل في الوضع غير المتصل وتتزامن فورياً باستخدام ElectricSQL و Next.js 15. يغطّي هذا الدليل إعداد محرك المزامنة Electric واستخدام Shape API وبناء واجهات تفاعلية تبقى متزامنة مع قاعدة بيانات PostgreSQL عبر جميع العملاء.

تتطلب تطبيقات الويب الحديثة تجارب متزامنة في الوقت الفعلي. يتوقع المستخدمون أن تتحدث البيانات فورياً عبر الأجهزة وأن يستمر عمل التطبيق حتى في حالة انقطاع الإنترنت. الأساليب التقليدية — الاستطلاع الدوري، وWebSockets، وإدارة الذاكرة المؤقتة يدوياً — تُضيف تعقيدات كبيرة إلى الكود.

ElectricSQL (أو ببساطة Electric) يحلّ هذه المشكلة من خلال محرك مزامنة مفتوح المصدر يعمل أمام قاعدة بيانات PostgreSQL. بدلاً من كتابة منطق مزامنة معقد، تعرّف Shapes — اشتراكات تصريحية تصف البيانات التي يجب بثّها إلى العميل. يتولى Electric الباقي: التحديثات الفورية، وقائمة انتظار التعديلات أثناء انقطاع الاتصال، والدمج بدون تعارضات.

في هذا الدليل، ستبني مدير مهام تعاونياً:

  • يزامن المهام فورياً عبر عدة علامات تبويب في المتصفح
  • يعمل في وضع عدم الاتصال ويتزامن تلقائياً عند عودة الاتصال
  • يستخدم useOptimistic من React 19 للاستجابة الفورية في الواجهة
  • يعمل على بنية تحتية مفتوحة المصدر بالكامل (PostgreSQL + Electric)

المتطلبات الأساسية

قبل البدء، تأكد من توفر:

  • Node.js 20 أو أحدث مع npm
  • Docker و Docker Compose
  • معرفة أساسية بـ Next.js App Router وخطافات React
  • إلمام بـ PostgreSQL وأساسيات SQL
  • محرر كود (يُنصح بـ VS Code)

ما هو ElectricSQL؟

Electric هو محرك مزامنة بُني بواسطة مطوّري PGLite. يستخدم النسخ المنطقي في PostgreSQL لبثّ التغييرات على مستوى الصفوف إلى الواجهة الأمامية في الوقت الفعلي عبر بروتوكول HTTP بسيط.

المفاهيم الأساسية:

  • Shapes: استعلام مباشر — يُخبر Electric بالصفوف والأعمدة التي يجب مزامنتها مع العميل. فكّر فيه كاشتراك في مجموعة فرعية مصفّاة من جدول PostgreSQL.
  • Electric Server: وكيل خفيف الوزن يتصل بـ PostgreSQL عبر النسخ المنطقي ويخدم Shapes عبر HTTP على المنفذ 3000.
  • @electric-sql/react: مكتبة العميل لـ React مع خطاف useShape() يشترك في Shape ويعيد بيانات تفاعلية تتحدث تلقائياً.

ما يميّز Electric عن البدائل كـ Supabase Realtime أو Convex:

  • لا ربط بمورّد محدد — يعمل مع أي قاعدة بيانات PostgreSQL قياسية
  • لا كود مزامنة مخصص — عرّف Shapes وEl Electric يفعل الباقي
  • بروتوكول HTTP — يعمل عبر الوكلاء وشبكات توصيل المحتوى وموازنات التحميل
  • يتوسع مع PostgreSQL — لا حاجة لبنية تحتية حالة إضافية

الخطوة 1: إعداد مشروع Next.js

أنشئ مشروع Next.js 15 جديد:

npx create-next-app@latest electric-tasks --typescript --tailwind --app
cd electric-tasks

ثبّت مكتبات عميل Electric:

npm install @electric-sql/react @electric-sql/client

ثبّت الحزم المساعدة للعمليات على جانب الخادم:

npm install postgres uuid
npm install -D @types/uuid

الخطوة 2: تشغيل PostgreSQL و Electric مع Docker

أنشئ ملف docker-compose.yml في جذر المشروع:

version: "3.8"
 
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: electric_tasks
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    command:
      - postgres
      - -c
      - wal_level=logical
 
  electric:
    image: electricsql/electric:latest
    environment:
      DATABASE_URL: postgresql://postgres:password@postgres:5432/electric_tasks
      AUTH_MODE: insecure
    ports:
      - "3000:3000"
    depends_on:
      - postgres
 
volumes:
  postgres_data:

العلامة wal_level=logical مطلوبة — يستخدم Electric النسخ المنطقي في PostgreSQL لتتبع التغييرات على مستوى الصفوف.

شغّل الخدمتين:

docker compose up -d

تحقق من صحة Electric بزيارة http://localhost:3000/v1/health — يجب أن ترى {"status":"ok"}.

الخطوة 3: إنشاء مخطط قاعدة البيانات

أنشئ الملف db/schema.sql:

CREATE TABLE IF NOT EXISTS tasks (
  id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  title       TEXT        NOT NULL,
  completed   BOOLEAN     NOT NULL DEFAULT FALSE,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

أنشئ db/migrate.mjs لتطبيق المخطط:

import postgres from "postgres";
import { readFileSync } from "fs";
 
const sql = postgres(process.env.DATABASE_URL);
const schema = readFileSync("./db/schema.sql", "utf8");
await sql.unsafe(schema);
console.log("تم تطبيق المخطط بنجاح");
await sql.end();

أنشئ .env.local:

DATABASE_URL=postgresql://postgres:password@localhost:5432/electric_tasks
NEXT_PUBLIC_ELECTRIC_URL=http://localhost:3000

نفّذ الترحيل:

node --env-file=.env.local db/migrate.mjs

الخطوة 4: تعريف أنواع TypeScript

أنشئ types/task.ts:

export interface Task {
  id: string;
  title: string;
  completed: boolean;
  created_at: string;
  updated_at: string;
}

الخطوة 5: بناء Server Actions للتعديلات

أنشئ app/actions.ts لمعالجة عمليات الكتابة على جانب الخادم:

"use server";
 
import postgres from "postgres";
import { v4 as uuidv4 } from "uuid";
 
const sql = postgres(process.env.DATABASE_URL!);
 
export async function createTask(title: string): Promise<void> {
  await sql`
    INSERT INTO tasks (id, title)
    VALUES (${uuidv4()}, ${title})
  `;
}
 
export async function toggleTask(id: string, completed: boolean): Promise<void> {
  await sql`
    UPDATE tasks
    SET completed = ${completed},
        updated_at = NOW()
    WHERE id = ${id}
  `;
}
 
export async function deleteTask(id: string): Promise<void> {
  await sql`DELETE FROM tasks WHERE id = ${id}`;
}

لاحظ غياب استدعاءات revalidatePath — محرك مزامنة Electric يبثّ التغييرات مباشرة لجميع العملاء المشتركين، مما يجعل إبطال الذاكرة المؤقتة يدوياً غير ضروري.

الخطوة 6: إنشاء خطاف Electric Shape

أنشئ hooks/useTasks.ts:

"use client";
 
import { useShape } from "@electric-sql/react";
import type { Task } from "@/types/task";
 
export function useTasks() {
  const { data, isLoading, error } = useShape<Task>({
    url: `${process.env.NEXT_PUBLIC_ELECTRIC_URL}/v1/shape`,
    params: {
      table: "tasks",
      order_by: "created_at",
    },
  });
 
  return {
    tasks: (data ?? []) as Task[],
    isLoading,
    error,
  };
}

خطاف useShape يفتح اتصال HTTP طويل الاستطلاع مع خادم Electric. عند تغيير أي صف في جدول tasks، يدفع Electric الفارق لجميع العملاء المتصلين ويُعيد الخطاف التصيير. لا حاجة لكود WebSocket معقد.

الخطوة 7: بناء مكوّن عنصر المهمة

أنشئ components/TaskItem.tsx:

"use client";
 
import { useOptimistic } from "react";
import { toggleTask, deleteTask } from "@/app/actions";
import type { Task } from "@/types/task";
 
export function TaskItem({ task }: { task: Task }) {
  const [optimisticTask, applyOptimistic] = useOptimistic(task);
 
  async function handleToggle() {
    applyOptimistic({ ...task, completed: !task.completed });
    await toggleTask(task.id, !task.completed);
  }
 
  return (
    <div className="flex items-center gap-3 p-3 rounded-lg border bg-white shadow-sm">
      <input
        type="checkbox"
        checked={optimisticTask.completed}
        onChange={handleToggle}
        className="h-4 w-4 cursor-pointer accent-blue-500"
      />
      <span
        className={`flex-1 text-sm ${
          optimisticTask.completed ? "line-through text-gray-400" : "text-gray-700"
        }`}
      >
        {task.title}
      </span>
      <button
        onClick={() => deleteTask(task.id)}
        className="text-xs text-red-400 hover:text-red-600 transition-colors"
      >
        حذف
      </button>
    </div>
  );
}

useOptimistic من React 19 يُحدّث خانة الاختيار فوراً قبل اكتمال العملية على الخادم، مما يزيل التأخر المحسوس.

الخطوة 8: بناء نموذج إضافة المهام

أنشئ components/TaskForm.tsx:

"use client";
 
import { useRef, useTransition } from "react";
import { createTask } from "@/app/actions";
 
export function TaskForm() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [isPending, startTransition] = useTransition();
 
  function handleSubmit(formData: FormData) {
    const title = (formData.get("title") as string).trim();
    if (!title) return;
    startTransition(async () => {
      await createTask(title);
      if (inputRef.current) inputRef.current.value = "";
    });
  }
 
  return (
    <form action={handleSubmit} className="flex gap-2">
      <input
        ref={inputRef}
        name="title"
        placeholder="ما الذي يجب إنجازه؟"
        disabled={isPending}
        className="flex-1 px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
        required
      />
      <button
        type="submit"
        disabled={isPending}
        className="px-4 py-2 bg-blue-500 text-white text-sm rounded-lg hover:bg-blue-600 disabled:opacity-50 transition-colors"
      >
        {isPending ? "جاري الإضافة..." : "إضافة"}
      </button>
    </form>
  );
}

الخطوة 9: مؤشر حالة المزامنة

أنشئ components/SyncStatus.tsx لعرض حالة اتصال Electric:

"use client";
 
import { useTasks } from "@/hooks/useTasks";
 
export function SyncStatus() {
  const { isLoading } = useTasks();
 
  return (
    <div className="flex items-center gap-1.5 text-xs text-gray-400">
      <span
        className={`h-2 w-2 rounded-full ${
          isLoading ? "bg-yellow-400 animate-pulse" : "bg-green-400"
        }`}
      />
      {isLoading ? "جاري الاتصال..." : "متصل"}
    </div>
  );
}

الخطوة 10: تجميع قائمة المهام

أنشئ components/TaskList.tsx:

"use client";
 
import { useTasks } from "@/hooks/useTasks";
import { TaskItem } from "./TaskItem";
import { TaskForm } from "./TaskForm";
import { SyncStatus } from "./SyncStatus";
 
export function TaskList() {
  const { tasks, isLoading, error } = useTasks();
 
  const pending = tasks.filter((t) => !t.completed);
  const done = tasks.filter((t) => t.completed);
 
  return (
    <div className="max-w-lg mx-auto p-6 space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold text-gray-900">المهام</h1>
        <SyncStatus />
      </div>
 
      <TaskForm />
 
      {error && (
        <p className="text-sm text-red-500 bg-red-50 px-3 py-2 rounded-lg">
          تعذّر الاتصال بـ Electric. تأكد من تشغيل الخادم على المنفذ 3000.
        </p>
      )}
 
      {!error && (
        <>
          {pending.length > 0 && (
            <section className="space-y-2">
              <p className="text-xs font-medium text-gray-400 uppercase tracking-wide">
                قيد الانتظار — {pending.length}
              </p>
              {pending.map((task) => (
                <TaskItem key={task.id} task={task} />
              ))}
            </section>
          )}
 
          {done.length > 0 && (
            <section className="space-y-2">
              <p className="text-xs font-medium text-gray-400 uppercase tracking-wide">
                مكتمل — {done.length}
              </p>
              {done.map((task) => (
                <TaskItem key={task.id} task={task} />
              ))}
            </section>
          )}
 
          {tasks.length === 0 && !isLoading && (
            <p className="text-sm text-gray-400 text-center py-8">
              لا توجد مهام بعد. أضف مهمة أعلاه!
            </p>
          )}
        </>
      )}
    </div>
  );
}

الخطوة 11: ربط الصفحة الرئيسية

عدّل app/page.tsx:

import { TaskList } from "@/components/TaskList";
 
export default function Home() {
  return (
    <main className="min-h-screen bg-gray-50 py-12">
      <TaskList />
    </main>
  );
}

الخطوة 12: اختبار المزامنة الفورية

شغّل خادم التطوير:

npm run dev

افتح http://localhost:3001 في علامتي تبويب منفصلتين وجرّب:

  1. أضف مهمة في علامة التبويب الأولى — تظهر في الثانية خلال نحو 100 ميلي ثانية
  2. فعّل خانة الاكتمال — تتحدث علامتا التبويب في آنٍ واحد
  3. احذف مهمة — تختفي في كل مكان فوراً
  4. افتح DevTools، اذهب إلى Network، وفلتر بكلمة shape — لاحظ طلبات HTTP الطويلة التي تقود المزامنة

الخطوة 13: المزامنة الجزئية باستخدام فلاتر Shape

إحدى أقوى ميزات Electric هي مزامنة البيانات التي يحتاجها المستخدم فقط. بدلاً من بثّ الجدول بأكمله، صفّ حسب معرّف المستخدم أو الحالة:

export function useUserTasks(userId: string) {
  const { data } = useShape<Task>({
    url: `${process.env.NEXT_PUBLIC_ELECTRIC_URL}/v1/shape`,
    params: {
      table: "tasks",
      where: `user_id = '${userId}'`,
    },
  });
  return (data ?? []) as Task[];
}

هذا ضروري للتطبيقات متعددة المستأجرين — يُنزّل كل مستخدم صفوفه الخاصة فقط، مما يقلل استهلاك الحزمة ويعزّز عزل البيانات.

الخطوة 14: النشر على بيئة الإنتاج

للإنتاج تحتاج:

  1. PostgreSQL مع تفعيل النسخ المنطقي — تدعم Neon وSupabase وAmazon RDS ذلك؛ فعّله بـ ALTER SYSTEM SET wal_level = logical;
  2. Electric Server كحاوية Docker على Fly.io أو Railway أو خادمك الخاص
  3. Next.js منشور على Vercel أو أي مضيف Node.js

إعداد Electric للإنتاج مع مصادقة JWT:

electric:
  image: electricsql/electric:latest
  environment:
    DATABASE_URL: ${DATABASE_URL}
    AUTH_MODE: jwt
    AUTH_JWT_ALG: ES256
    AUTH_JWT_KEY: ${ELECTRIC_JWT_PUBLIC_KEY}
  ports:
    - "3000:3000"

في الإنتاج، استبدل مصادقة insecure بـ JWT. تُصدر مسارات API في Next.js رموز JWT قصيرة العمر تُخوّل الوصول إلى Shapes محددة، مما يمنع العملاء من الوصول لبيانات لا يملكونها.

قائمة التحقق قبل الإطلاق

تحقق من التالي قبل الشحن:

  • تظهر المهام في علامة التبويب الثانية خلال 200 ميلي ثانية من إنشائها
  • يُحدّث تبديل الاكتمال جميع علامات التبويب المفتوحة في الوقت الفعلي
  • تُحمّل المهام عند تحديث الصفحة دون وميض تحميل مرئي
  • يزامن قطع الاتصال بالشبكة وإعادته أي تعديلات مُخزّنة في قائمة الانتظار
  • تُعيد فلاتر Shape الصفوف المطابقة فقط

استكشاف الأخطاء وإصلاحها

حاوية Electric تتوقف فوراً: تحقق من أن wal_level في PostgreSQL مضبوط على logical. تحقق بـ psql -c "SHOW wal_level;" — يجب أن يعيد logical لا replica أو minimal.

useShape يعيد مصفوفة فارغة: تأكد أن NEXT_PUBLIC_ELECTRIC_URL في .env.local صحيح وأنه مكشوف للعميل. تحقق من علامة تبويب Network في المتصفح لطلبات /v1/shape الفاشلة.

أخطاء CORS في المتصفح: اضبط متغير البيئة ALLOW_ORIGIN في Electric، أو وجّه الطلبات عبر Next.js rewrites:

// next.config.js
module.exports = {
  async rewrites() {
    return [
      {
        source: "/electric/:path*",
        destination: "http://localhost:3000/:path*",
      },
    ];
  },
};

ثم عدّل NEXT_PUBLIC_ELECTRIC_URL إلى /electric.

الخطوات التالية

الآن بعد أن أصبحت المزامنة الفورية تعمل، فكّر في هذه التحسينات:

  • إضافة المصادقة مع Better Auth أو Clerk وإصدار رموز JWT خاصة بكل مستخدم
  • تعدد المستأجرين — أضف عمود workspace_id وصفّ Shapes لكل مستأجر
  • مؤشرات الحضور — ادمج بيانات Electric مع طبقة WebSocket صغيرة لمواضع المؤشرات
  • التعديلات دون اتصال — استخدم ميزة الكتابة القادمة في Electric لتخزين التعديلات محلياً

الخلاصة

يُحوّل ElectricSQL تعقيد التطبيقات الفورية إلى نموذج تصريحي بسيط. بدلاً من إدارة اتصالات WebSocket ومنطق إعادة الاتصال واستراتيجيات الدمج، تعرّف Shapes وتترك Electric يقوم بالعمل الشاق.

البنية نظيفة: تعالج Server Actions في Next.js الكتابة على PostgreSQL، ويُوزّع محرك المزامنة في Electric كل تغيير على جميع العملاء المشتركين فورياً، ويُوصل useShape تدفق البيانات التفاعلي إلى مكوّناتك. المنظومة بأكملها مفتوحة المصدر، قابلة للاستضافة الذاتية، ومبنية على PostgreSQL الذي تعرفه بالفعل.

بالنسبة للفرق التي تنتقل من حلول الاستطلاع الدوري، يُعدّ التحسّن في الأداء المحسوس — والتقليص الكبير في كود المزامنة — من أكثر الإضافات أثراً على مجموعة Next.js الحديثة في 2026.