الكتابات/tutorial/2026/06
Tutorial4 يونيو 2026·32 دقيقة

بناء تطبيق Local-First باستخدام Rocicorp Zero و Next.js (2026)

تعلّم كيفية بناء متتبّع مشكلات فوري يعمل بأسلوب local-first باستخدام Rocicorp Zero و Next.js. يغطّي هذا الدرس مخطط Zero والعلاقات والاستعلامات المتزامنة مع الصلاحيات والمُغيِّرات المخصّصة وخادم zero-cache وواجهة تفاعلية عبر خطّاف useQuery المدعوم بـ PostgreSQL.

يحوّل Rocicorp Zero الشبكة إلى تفصيل تقني خفي. فبدلاً من كتابة طلبات fetch ومؤشرات التحميل ومنطق إبطال الذاكرة المؤقتة، تكتب استعلامات تقرأ مباشرةً من مخزن بيانات محلي. يقوم Zero بنسخ مجموعة فرعية من قاعدة بيانات PostgreSQL إلى كل عميل بناءً على الاستعلامات التي ينفّذها، ويزامن عمليات الكتابة مع الخادم في الخلفية، ويُبقي كل عميل متّصل محدّثاً في الوقت الفعلي. والنتيجة تبدو وكأنها تطبيق أصلي — القراءات فورية، والكتابات متفائلة، والعمل دون اتصال يعمل في الغالب تلقائياً.

في عام 2026 انتقلت حركة local-first من العروض البحثية إلى أدوات الإنتاج الفعلية. ويُعدّ Zero، من الفريق وراء Replicache و Reflect، أحد أكثر الخيارات نضجاً: محرّك مزامنة قائم على الاستعلامات ينسخ Postgres إلى SQLite على الخادم ويرسل بالضبط الصفوف التي يحتاجها كل عميل. في هذا الدرس ستبني متتبّع مشكلات تعاونياً من الصفر وتتعلّم تدفّق بيانات Zero بالكامل.

المتطلبات المسبقة

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

  • Node.js 20+ مثبّت (node --version)
  • مثيل PostgreSQL 15+ قيد التشغيل مع تفعيل النسخ المنطقي (سنستخدم Docker)
  • معرفة أساسية بـ React و Next.js App Router
  • إلمام بـ TypeScript و async/await
  • محرّر أكواد (يُنصح بـ VS Code)

لا تحتاج إلى خبرة سابقة في محرّكات المزامنة — سنشرح كل مفهوم أثناء التقدّم.

ما الذي ستبنيه

متتبّع مشكلات متعدّد المستخدمين حيث:

  • تُحمَّل المشكلات والتعليقات فوراً من مخزن محلي دون أي مؤشرات تحميل
  • تظهر التعديلات على الفور (بشكل متفائل) وتتوافق مع الخادم تلقائياً
  • يرى كل متصفّح متّصل التغييرات في الوقت الفعلي دون إعادة جلب يدوية
  • تُطبَّق الصلاحيات على جانب الخادم: لا يزامن المستخدمون سوى المشكلات التي يملكونها أو المشاركة معهم

تتألّف طبقة البيانات بالكامل من مخطط واستعلامات ومُغيِّرات فقط — لا نقاط نهاية REST ولا محلّلات GraphQL ولا أنابيب WebSocket.

كيف يعمل Zero (النموذج الذهني)

يتكوّن Zero من ثلاثة أجزاء متحرّكة:

  1. zero-cache — عملية خادم تتّصل بـ Postgres لديك (المصدر الأعلى "upstream")، وتنسخه إلى نسخة SQLite محلية، وتقدّم البيانات للعملاء عبر WebSockets.
  2. العميل — يحتفظ تطبيق Next.js بمخزن بيانات محلي. تقرأ الاستعلامات منه بشكل متزامن، ويُغذّى المخزن المحلي ويُحدَّث عبر zero-cache.
  3. الاستعلامات والمُغيِّرات المتزامنة — دوال TypeScript تحدّد ما يمكن للعميل قراءته وكيف يمكنه الكتابة. تعمل على كلٍّ من العميل (للحصول على استجابة فورية) والخادم (كمصدر للحقيقة).

عند استدعاء استعلام، يسجّله Zero لدى zero-cache الذي يبثّ الصفوف المطابقة ثم يدفع كل تغيير مستقبلي. وعند استدعاء مُغيِّر، يطبّقه Zero محلياً أولاً ثم يرسله إلى الخادم للتحقّق منه وحفظه.

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

أنشئ مشروع Next.js جديداً وشغّل مثيل Postgres.

npx create-next-app@latest zero-tracker --typescript --app --tailwind
cd zero-tracker

شغّل Postgres مع تفعيل النسخ المنطقي عبر Docker Compose. يحتاج Zero إلى wal_level=logical لبثّ التغييرات.

# docker-compose.yml
services:
  postgres:
    image: postgres:16
    command: postgres -c wal_level=logical
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: zero
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:

شغّله:

docker compose up -d

أنشئ الجداول التي سينسخها Zero. احفظ هذا في db/init.sql ونفّذه على قاعدة البيانات.

CREATE TABLE "user" (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL
);
 
CREATE TABLE issue (
  id TEXT PRIMARY KEY,
  title TEXT NOT NULL,
  description TEXT NOT NULL DEFAULT '',
  status TEXT NOT NULL DEFAULT 'open',
  "creatorID" TEXT NOT NULL REFERENCES "user"(id),
  "createdAt" BIGINT NOT NULL
);
 
CREATE TABLE comment (
  id TEXT PRIMARY KEY,
  body TEXT NOT NULL,
  "issueID" TEXT NOT NULL REFERENCES issue(id),
  "creatorID" TEXT NOT NULL REFERENCES "user"(id),
  "createdAt" BIGINT NOT NULL
);
docker compose exec -T postgres psql -U postgres -d zero < db/init.sql

الخطوة 2: تثبيت Zero وتعريف المخطط

ثبّت حزمة Zero:

npm install @rocicorp/zero zod

المخطط هو قلب تطبيق Zero. فهو يعكس جداول Postgres لديك في TypeScript ويمنحك مُنشئ استعلامات مكتمل الأنواع. أنشئ zero/schema.ts:

// zero/schema.ts
import {
  createSchema,
  createBuilder,
  table,
  string,
  number,
} from '@rocicorp/zero'
 
const user = table('user')
  .columns({
    id: string(),
    name: string(),
  })
  .primaryKey('id')
 
const issue = table('issue')
  .columns({
    id: string(),
    title: string(),
    description: string(),
    status: string(),
    creatorID: string(),
    createdAt: number(),
  })
  .primaryKey('id')
 
const comment = table('comment')
  .columns({
    id: string(),
    body: string(),
    issueID: string(),
    creatorID: string(),
    createdAt: number(),
  })
  .primaryKey('id')
 
export const schema = createSchema({
  tables: [user, issue, comment],
})
 
// مُنشئ استعلامات مكتمل الأنواع نستخدمه في كل مكان نقرأ فيه البيانات.
export const zql = createBuilder(schema)
 
declare module '@rocicorp/zero' {
  interface DefaultTypes {
    schema: typeof schema
  }
}

لاحظ أن أنواع الأعمدة (string() و number()) تصف الشكل على جانب العميل. يُحوّل Zero طوابع BIGINT الزمنية في Postgres إلى أرقام JavaScript نيابةً عنك.

الخطوة 3: تعريف العلاقات

تتيح لك العلاقات الانتقال من مشكلة إلى تعليقاتها ومنشئها في استعلام واحد. أضفها إلى zero/schema.ts:

import {relationships} from '@rocicorp/zero'
 
const issueRelationships = relationships(issue, ({one, many}) => ({
  creator: one({
    sourceField: ['creatorID'],
    destField: ['id'],
    destSchema: user,
  }),
  comments: many({
    sourceField: ['id'],
    destField: ['issueID'],
    destSchema: comment,
  }),
}))
 
const commentRelationships = relationships(comment, ({one}) => ({
  creator: one({
    sourceField: ['creatorID'],
    destField: ['id'],
    destSchema: user,
  }),
}))

ثم سجّلها في createSchema:

export const schema = createSchema({
  tables: [user, issue, comment],
  relationships: [issueRelationships, commentRelationships],
})

تعيد علاقة one صفاً مرتبطاً واحداً (منشئ المشكلة)، بينما تعيد many مصفوفة (تعليقات المشكلة).

الخطوة 4: كتابة استعلامات متزامنة مع صلاحيات

في إصدارات Zero الحديثة، تُعدّ الاستعلامات المتزامنة الطريقة التي تحدّد بها ما هي البيانات القابلة للقراءة ومن يستطيع قراءتها. كل استعلام هو دالة TypeScript تتلقّى وسيط ctx يحمل المستخدم المُصادَق عليه. وبما أن العميل لا يستطيع العبث بـ ctx، فإن الترشيح بناءً عليه يمثّل طبقة صلاحيات القراءة لديك.

أنشئ zero/queries.ts:

// zero/queries.ts
import {defineQueries, defineQuery} from '@rocicorp/zero'
import {zql} from './schema'
 
export const queries = defineQueries({
  // كل المشكلات التي أنشأها المستخدم الحالي، الأحدث أولاً،
  // مع توفّر منشئها وتعليقاتها.
  myIssues: defineQuery(({ctx}) =>
    zql.issue
      .where('creatorID', ctx.userID)
      .related('creator')
      .related('comments', q => q.related('creator'))
      .orderBy('createdAt', 'desc'),
  ),
 
  // مشكلة واحدة مع سلسلة تعليقاتها الكاملة.
  issueDetail: defineQuery((id: string, {ctx}) =>
    zql.issue
      .where('id', id)
      .where('creatorID', ctx.userID)
      .related('comments', q =>
        q.related('creator').orderBy('createdAt', 'asc'),
      )
      .one(),
  ),
})

بعض النقاط الجديرة بالذكر:

  • ctx.userID يتحكّم به الخادم. فحتى لو عمل الاستعلام أيضاً على العميل للحصول على نتائج فورية، يعيد الخادم تنفيذه باعتباره السلطة المرجعية، لذا لا يستطيع عميل خبيث قراءة مشكلات مستخدم آخر.
  • .related() يقبل ردّ نداء لاستعلام فرعي، مما يتيح ترتيب الصفوف المرتبطة أو تقييدها أكثر.
  • .one() يعيد صفاً واحداً أو undefined بدلاً من مصفوفة.

هذا تحوّل كبير عن REST: لا توجد نقطة نهاية لكل عرض. أنت تصف شكل البيانات التي تحتاجها الشاشة، ويُبقيها Zero حيّة.

الخطوة 5: تعريف المُغيِّرات

تمرّ عمليات الكتابة عبر المُغيِّرات — دوال مكتملة الأنواع يُتحقّق منها بواسطة Zod. يعمل المُغيِّر بشكل متفائل على العميل وبشكل مرجعي على الخادم. أنشئ zero/mutators.ts:

// zero/mutators.ts
import {defineMutators, defineMutator} from '@rocicorp/zero'
import {z} from 'zod'
 
export const mutators = defineMutators({
  createIssue: defineMutator(
    z.object({
      id: z.string(),
      title: z.string().min(1).max(120),
      description: z.string().default(''),
    }),
    async ({tx, ctx, args}) => {
      await tx.mutate.issue.insert({
        id: args.id,
        title: args.title,
        description: args.description,
        status: 'open',
        creatorID: ctx.userID,
        createdAt: Date.now(),
      })
    },
  ),
 
  addComment: defineMutator(
    z.object({
      id: z.string(),
      issueID: z.string(),
      body: z.string().min(1),
    }),
    async ({tx, ctx, args}) => {
      await tx.mutate.comment.insert({
        id: args.id,
        issueID: args.issueID,
        body: args.body,
        creatorID: ctx.userID,
        createdAt: Date.now(),
      })
    },
  ),
 
  closeIssue: defineMutator(
    z.object({id: z.string()}),
    async ({tx, args}) => {
      await tx.mutate.issue.update({id: args.id, status: 'closed'})
    },
  ),
})

ولأن نفس كود المُغيِّر يعمل على الجانبين، يوجد منطق صلاحيات الكتابة والتحقّق في مكان واحد بالضبط. وتعيين creatorID من ctx.userID بدلاً من args يعني أن العميل لا يستطيع أبداً تزوير الملكية.

الخطوة 6: تشغيل zero-cache

zero-cache هو المحرّك الذي ينسخ Postgres ويقدّم البيانات للعملاء. اضبطه عبر متغيّرات البيئة. أنشئ .env:

ZERO_UPSTREAM_DB="postgres://postgres:pass@localhost:5432/zero"
ZERO_REPLICA_FILE="/tmp/zero-tracker.db"
ZERO_QUERY_URL="http://localhost:3000/api/zero/query"
ZERO_MUTATE_URL="http://localhost:3000/api/zero/mutate"
NEXT_PUBLIC_ZERO_CACHE_URL="http://localhost:4848"

أضف سكربتاً إلى package.json وشغّله:

{
  "scripts": {
    "zero": "zero-cache-dev"
  }
}
npm run zero

ينسخ التشغيل الأول جداولك إلى ملف نسخة SQLite. أبقِ هذه العملية قيد التشغيل في نافذة طرفية خاصة بها إلى جانب next dev.

الخطوة 7: توصيل ZeroProvider

يُنشئ الموفّر مثيل Zero على جانب العميل ويتيحه للخطافات. في تطبيق حقيقي يأتي userID ورمز auth من موفّر الجلسة لديك؛ هنا نبقيه بسيطاً. أنشئ app/providers.tsx:

// app/providers.tsx
'use client'
 
import {ZeroProvider} from '@rocicorp/zero/react'
import {schema} from '@/zero/schema'
import {mutators} from '@/zero/mutators'
 
const cacheURL = process.env.NEXT_PUBLIC_ZERO_CACHE_URL!
 
export function Providers({children}: {children: React.ReactNode}) {
  // استبدل هذا بالمستخدم المُصادَق عليه الحقيقي مع JWT.
  const userID = 'user-1'
  const auth = 'demo-token'
 
  return (
    <ZeroProvider
      userID={userID}
      auth={auth}
      context={{userID}}
      cacheURL={cacheURL}
      schema={schema}
      mutators={mutators}
    >
      {children}
    </ZeroProvider>
  )
}

تصبح الخاصية context هي ctx الذي تتلقّاه استعلاماتك ومُغيِّراتك المتزامنة على العميل. ركّب الموفّر في app/layout.tsx بتغليف children بمكوّن Providers.

الخطوة 8: بناء واجهة تفاعلية

الآن تأتي الثمرة. يقرأ خطّاف useQuery من المخزن المحلي ويعيد العرض تلقائياً كلما تغيّرت البيانات — سواء جاء التغيير من هذا المستخدم أو مستخدم آخر أو المزامنة في الخلفية. أنشئ app/page.tsx:

// app/page.tsx
'use client'
 
import {useQuery, useZero} from '@rocicorp/zero/react'
import {queries} from '@/zero/queries'
import {mutators} from '@/zero/mutators'
import {useState} from 'react'
 
export default function Home() {
  const z = useZero<typeof mutators>()
  const [issues] = useQuery(queries.myIssues())
  const [title, setTitle] = useState('')
 
  async function handleCreate() {
    if (!title.trim()) return
    await z.mutate.createIssue({
      id: crypto.randomUUID(),
      title,
      description: '',
    })
    setTitle('')
  }
 
  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="mb-6 text-2xl font-bold">مشكلاتي</h1>
 
      <div className="mb-8 flex gap-2">
        <input
          value={title}
          onChange={e => setTitle(e.target.value)}
          placeholder="عنوان مشكلة جديدة"
          className="flex-1 rounded border px-3 py-2"
        />
        <button
          onClick={handleCreate}
          className="rounded bg-violet-600 px-4 py-2 text-white"
        >
          إضافة
        </button>
      </div>
 
      <ul className="space-y-3">
        {issues.map(issue => (
          <li key={issue.id} className="rounded border p-4">
            <div className="flex items-center justify-between">
              <span className="font-medium">{issue.title}</span>
              <span className="text-sm text-gray-500">
                {issue.comments.length} تعليقات
              </span>
            </div>
            <button
              onClick={() => z.mutate.closeIssue({id: issue.id})}
              className="mt-2 text-sm text-gray-400 hover:text-red-500"
            >
              إغلاق
            </button>
          </li>
        ))}
      </ul>
    </main>
  )
}

اكتب عنواناً، انقر إضافة، وستظهر المشكلة قبل اكتمال رحلة الشبكة ذهاباً وإياباً. افتح الصفحة نفسها في علامة تبويب ثانية وراقب المشكلات الجديدة تتدفّق حيّة — دون استطلاع دوري ودون refetch يدوي.

الخطوة 9: المُغيِّرات المتفائلة عملياً

لقد كتبت بالفعل كوداً متفائلاً دون أن تدرك ذلك. عند تشغيل z.mutate.createIssue(...)، يقوم Zero بما يلي:

  1. يطبّق الإدراج على المخزن المحلي بشكل متزامن، فيعيد useQuery العرض فوراً.
  2. يرسل التغيير إلى الخادم للتحقّق منه وحفظه في Postgres.
  3. إذا رفضه الخادم (مثلاً عنوان أطول من 120 حرفاً يفشل في تحقّق Zod)، يقوم Zero بالتراجع عن التغيير المحلي تلقائياً.

أنت لا تلمس أبداً حالة تحميل ولا تراجعاً يدوياً عرضةً للأخطاء. ولإظهار أخطاء الخادم، غلّف الاستدعاء بـ try/catch واعرض إشعاراً عند الفشل.

الخطوة 10: معالجات الاستعلام والكتابة على الخادم

لكي تكون الصلاحيات مرجعية، يستدعي zero-cache تطبيقك مرةً أخرى لتشغيل الاستعلامات والمُغيِّرات المتزامنة بالسياق المُصادَق عليه الحقيقي. أنشئ مسارَي API المشار إليهما في ملف .env.

معالج الاستعلام:

// app/api/zero/query/route.ts
import {handleQueryRequest} from '@rocicorp/zero/server'
import {mustGetQuery} from '@rocicorp/zero'
import {queries} from '@/zero/queries'
import {schema} from '@/zero/schema'
import {authenticate} from '@/lib/auth'
 
export async function POST(req: Request) {
  const session = await authenticate(req.headers.get('Cookie'))
 
  const result = await handleQueryRequest(
    (name, args) => {
      const query = mustGetQuery(queries, name)
      return query.fn({args, ctx: {userID: session.userID}})
    },
    schema,
    req,
  )
 
  return Response.json(result)
}

يتبع معالج الكتابة الشكل نفسه باستخدام handlePushRequest وdbProvider لديك (محوّل Drizzle أو Postgres مباشر). والفكرة الأساسية: ctx العميل مجرّد تلميح للتفاؤل، لكن الخادم يشتقّ ctx من كوكي الجلسة ويعيد تشغيل كل استعلام ومُغيِّر باعتباره مصدر الحقيقة. والعميل المُتلاعَب به يحصل ببساطة على نتائج صحيحة مُرشَّحة بالصلاحيات.

اختبار التنفيذ

تحقّق من الدورة الكاملة:

  1. القراءات الفورية: أعد تحميل الصفحة — تظهر المشكلات دون أي مؤشر تحميل.
  2. المزامنة الفورية: افتح علامتَي تبويب جنباً إلى جنب؛ أنشئ مشكلة في إحداهما وتأكّد من ظهورها في الأخرى خلال لحظات.
  3. الكتابات المتفائلة: قلّل سرعة الشبكة في DevTools إلى "Slow 3G" وأضف مشكلة — ينبغي أن تظهر فوراً ثم تُحفظ.
  4. الصلاحيات: غيّر userID في Providers إلى user-2 وتأكّد أنك لم تعد ترى مشكلات user-1.
  5. تراجع التحقّق: أرسل مؤقتاً عنواناً من 200 حرف وتأكّد من اختفاء الصف المتفائل بعد رفض الخادم له.

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

zero-cache يتوقّف مع خطأ نسخ. يجب أن يعمل Postgres مع wal_level=logical. تحقّق بـ SHOW wal_level; — ينبغي أن يطبع logical. يضبط هذا علم command في Docker أعلاه.

الاستعلامات تعيد مصفوفات فارغة رغم وجود صفوف. استعلامك المتزامن يُرشّح بناءً على ctx.userID، وcreatorID في بيانات البذر لا يطابق userID الموفّر. ابذُر صفاً بـ creatorID = 'user-1'.

الأنواع هي any في مُنشئ الاستعلامات. تأكّد من وجود كتلة declare module '@rocicorp/zero' في schema.ts كي يلتقط المُنشئ أنواع مخططك.

التغييرات لا تتزامن بين علامات التبويب. تأكّد أن NEXT_PUBLIC_ZERO_CACHE_URL يشير إلى zero-cache قيد التشغيل (المنفذ الافتراضي 4848) وأن كلاً من الذاكرة المؤقتة وnext dev يعملان.

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

  • أضِف المشاركة: أدخِل جدول issueShare ووسّع myIssues بشرط or() كي يرى المستخدمون المشكلات المشاركة معهم، على غرار نمط صلاحيات القراءة.
  • استبدل مصادقة العرض التوضيحي بجلسة حقيقية باستخدام Better Auth أو Auth.js v5.
  • ولّد مخطط Zero من مخطط Drizzle ORM موجود باستخدام drizzle-zero.
  • قارن المقاربات عبر درسَي ElectricSQL و TanStack DB لاختيار محرّك المزامنة المناسب لمنظومتك.

الخلاصة

لقد بنيت متتبّع مشكلات فورياً يعمل بأسلوب local-first دون كتابة أي طلب fetch أو مؤشر تحميل أو خطّاف لإبطال الذاكرة المؤقتة. ونموذج Zero — مخطط مكتمل الأنواع، واستعلامات متزامنة تعمل أيضاً كصلاحيات قراءة، ومُغيِّرات مُتحقَّق منها تعمل على العميل والخادم معاً — يطوي مكدّس جلب البيانات المعتاد إلى حفنة من الدوال النقية. القراءات فورية لأنها تصيب مخزناً محلياً؛ والكتابات تبدو فورية لأنها متفائلة؛ ويبقى كل عميل متّسقاً لأن zero-cache يبثّ التغييرات من Postgres في الوقت الفعلي.

والدرس الأعمق معماري: عندما تكون المزامنة عنصراً بدائياً بدلاً من شيء تجمّعه من REST و WebSockets وذاكرة مؤقتة على العميل، تختفي ببساطة فئات كاملة من العلل — البيانات القديمة، وحالات التسابق، وعمليات الإبطال المنسية. ومع نضوج أدوات local-first خلال عام 2026، يُعدّ Zero خياراً افتراضياً قوياً للتطبيقات التعاونية التي تحتاج أن تبدو فورية.