---
title: "Cloudflare D1: حفظ المسودات وإدارتها بـ SQL"
description: "أضف حفظ المسودات إلى تطبيق Hono باستخدام Cloudflare D1، وتعلّم تصميم الجداول وتعديل بنيتها، وكتابة الاستعلامات، ومعالجة تعارض التعديلات، وفصل بيانات التجربة عن بيانات الموقع الفعلي."
author: "Omar Albeik"
date: 2026-11-13
type: tutorial
topics: [infrastructure]
language: ar
reading_time_minutes: 23
canonical_url: https://omaralbeik.com/ar/blog/series/cloudflare/understanding-cloudflare-d1
translation_url: https://omaralbeik.com/en/blog/series/cloudflare/understanding-cloudflare-d1
source_url: https://omaralbeik.com/ar/blog/series/cloudflare/understanding-cloudflare-d1.md
---

# Cloudflare D1: حفظ المسودات وإدارتها بـ SQL

الأداة التي بنيناها في [الجزء الرابع](/ar/blog/series/cloudflare/hosting-a-website-on-cloudflare-workers) تقدّر مدة قراءة النص، لكنها لا تحفظه. يكفي ذلك لتجربة سريعة. أما إذا كنت تكتب مقالا وتريد العودة إليه غدا، فستحتاج إلى مكان تحتفظ فيه بمسوداتك.

سنضيف هذه الإمكانية: حفظ عنوان ونص، وعرض أحدث المسودات، وفتح إحداها لمتابعة تحريرها. وسنعالج مشكلة تظهر بمجرد فتح المسودة في تبويبين: كيف نمنع أحدهما من محو التعديلات التي حفظها الآخر؟

سنستخدم [Cloudflare D1](https://developers.cloudflare.com/d1/)، وهي خدمة قواعد بيانات SQL مبنية على SQLite وتتولى Cloudflare تشغيلها. سنبدأ بجدول واحد، ثم نبني الاستعلامات التي نحتاجها، مع الانتباه إلى صاحب كل مسودة وما يحدث عند تكرار طلب الحفظ.

للمتابعة، افتح مشروع `reading-api` المبني بـ Hono من الجزأين السابقين. سنحتفظ بالملف `src/analyze.ts`، والمسارات الحالية، وإعدادات تقديم الملفات الثابتة. سنبني هنا الواجهة البرمجية لحفظ المسودات ونختبرها محليا، ثم يصبح ربطها بأزرار الحفظ والفتح في الصفحة خطوة مستقلة وواضحة.

- [متى نختار D1؟](#متى-نختار-d1)
- [إنشاء القاعدة المحلية وجدول المسودات](#إنشاء-القاعدة-المحلية-وجدول-المسودات)
- [حفظ المسودات](#حفظ-مسودة) و[تجربة أول طلب](#تجربة-أول-عملية-حفظ)
- [معالجة التعديلات من تبويبين](#عندما-تفتح-المسودة-في-تبويبين)
- [الفهارس والتكلفة والاستعداد للنشر](#فهرس-يناسب-الاستعلام)

## متى نختار D1؟

أول ما نحتاجه من قائمة المسودات هو: **اعرض مسودات هذا المستخدم، من الأحدث إلى الأقدم**. وقد نريد لاحقا عرض المسودات العربية وحدها، أو إضافة وسوم، أو حساب عدد المسودات في كل شهر. يمكن التعبير عن هذه الطلبات مباشرة باستعلامات SQL.

يصل تطبيق Worker إلى القاعدة من خلال ربط، أو binding، مثل `env.DB`. لا نضع كلمة مرور للقاعدة في المتصفح، ولا ندير مجموعة اتصالات بها داخل Worker. يرسل المتصفح طلبا إلى واجهتنا البرمجية، وتختار شيفرة الخادم الاستعلام المناسب.

تختلف هذه الحاجة عن استخدامات [خدمات التخزين الأخرى لدى Cloudflare](https://developers.cloudflare.com/workers/platform/storage-options/):

| ما الذي تحتاجه؟ | خدمة مناسبة للبدء |
| --- | --- |
| مسودات مرتبطة بمستخدميها، مع البحث فيها وترتيبها | D1 |
| قيم نقرأها كثيرا بمفاتيح معروفة، ويمكن قبول تأخر ظهور تحديثاتها | Workers KV |
| ملفات مرفوعة، مثل الصور ومستندات PDF | R2 |
| تنسيق العمل بين المشاركين في جلسة تحرير مباشر | Durable Objects |
| وصل Workers بقاعدة PostgreSQL أو MySQL موجودة | Hyperdrive |

تستخدم D1 صيغة SQL الخاصة بـ SQLite، ولها حدود تشغيل يجب مراعاتها. لكل قاعدة نسخة رئيسية تستقبل عمليات الكتابة. لذلك قد يصل الطلب إلى Worker قريب من المستخدم، ثم يحتاج إلى الاتصال بقاعدة في منطقة أخرى. سنعود إلى نسخ القراءة لاحقا.

يكفينا الآن جدول واحد. نخزّن النص الأصلي ولغته وسرعة القراءة المختارة، ونحسب تقدير مدة القراءة عند الحاجة باستخدام `analyzeReading`. لو خزّنا عدد الكلمات أيضا، لوجب علينا تحديثه مع النص ومراجعته كلما تغيرت طريقة الحساب.

## إنشاء القاعدة المحلية وجدول المسودات

أضف الربط التالي إلى المستوى الأعلى من `wrangler.jsonc`. احتفظ بالإعدادات الحالية، بما فيها `assets` و`vars`:

```jsonc title="wrangler.jsonc · أضف هذه الخاصية"
{
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "reading-drafts",
      "database_id": "00000000-0000-0000-0000-000000000000"
    }
  ]
}
```

المعرّف المؤلف من أصفار قيمة مؤقتة للتجربة المحلية. يستطيع [Wrangler تشغيل D1 محليا](https://developers.cloudflare.com/d1/best-practices/local-development/) دون إنشاء قاعدة على Cloudflare، ويحفظ حالتها داخل `.wrangler`. تبقى المسودات بعد إعادة تشغيل خادم التطوير، لكنها تُحذف إذا حذفت هذا المجلد. أبقِ `.wrangler/` خارج Git.

نحتاج أيضا إلى مستخدم ثابت للتجربة. أضف السطر التالي إلى `.dev.vars`، وتأكد من استثناء هذا الملف من Git:

```dotenv title=".dev.vars · للتجربة المحلية فقط"
LOCAL_DRAFTS="true"
```

يفعّل هذا الإعداد مستخدما تجريبيا واحدا؛ وهو لا يتحقق من هوية من يرسل الطلب. استخدم نصوصا تجريبية، وشغّل الخادم على `127.0.0.1`، ولا تضف هذا الإعداد إلى أي بيئة منشورة. عند غيابه تعيد مسارات المسودات الاستجابة `503`، بينما تبقى واجهة حساب مدة القراءة متاحة.

ولّد تعريفات الأنواع للربط الجديد، ثم أنشئ أول ملف لتعديل بنية القاعدة:

```bash
npx wrangler types --env-interface CloudflareBindings
npx wrangler d1 migrations create reading-drafts create_drafts
```

ينشئ Wrangler ملف SQL مرقّما داخل `migrations/`. إذا كان هذا أول ملف في المجلد، فسيكون اسمه `0001_create_drafts.sql`. ضع فيه ما يلي:

```sql title="migrations/0001_create_drafts.sql"
CREATE TABLE drafts (
  id TEXT PRIMARY KEY NOT NULL,
  owner_id TEXT NOT NULL,
  title TEXT NOT NULL,
  text TEXT NOT NULL,
  language TEXT NOT NULL CHECK (language IN ('en', 'ar')),
  words_per_minute INTEGER NOT NULL
    CHECK (words_per_minute BETWEEN 80 AND 600),
  version INTEGER NOT NULL DEFAULT 1,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

CREATE INDEX drafts_owner_updated
  ON drafts (owner_id, updated_at DESC, id DESC);
```

لكل مسودة معرّف يولّده الخادم، ومعرّف لصاحبها. نخزّن الوقت بعدد المللي ثانية منذ بداية توقيت Unix، كما تعيده `Date.now()`. ويبدأ رقم النسخة، `version`، من `1`، ثم يزداد مع كل تحديث ناجح. سنستخدمه بعد قليل لاكتشاف محاولات الحفظ المبنية على نسخة قديمة.

تتحقق القاعدة من اللغة المسموحة ومن نطاق سرعة القراءة. وسنتحقق في الواجهة البرمجية من العنوان والنص أيضا، حتى نعيد رسالة مفهومة للمستخدم قبل محاولة كتابة بيانات غير صالحة.

طبّق الملف على **القاعدة المحلية**:

```bash
npx wrangler d1 migrations apply reading-drafts --local
```

نسجّل كل تغيير في بنية القاعدة في [ملف migration](https://developers.cloudflare.com/d1/reference/migrations/)، ويتابع Wrangler الملفات التي سبق تطبيقها. حين تحتاج إلى تغيير جديد، أنشئ ملفا جديدا بدلا من تعديل ملف نُفّذ في بيئة أخرى. احفظ هذه الملفات في Git مع الشيفرة التي تعتمد عليها.

## تحديد صاحب المسودة مسؤولية الخادم

معرفة معرّف مسودة لا تمنح صاحب الطلب حق قراءتها أو تعديلها.

لهذا نضيف معرّف صاحبها إلى شرط `WHERE` في كل استعلام قراءة أو تحديث أو حذف. في التطبيق المنشور، نستخرج هذا المعرّف من جلسة تسجيل دخول تحققنا من صحتها. لو أخذنا `ownerId` من JSON أو من رابط الطلب، لأتحنا للمتصل اختيار مسودات أي مستخدم يريد.

**أي مسودات سيعرضها التطبيق؟**

لنفترض أن لدينا ثلاث مسودات: «ملاحظات DNS» و«قائمة مراجعة Worker» لعمر، و«خطة الإطلاق» للينا. اختر استعلاما لترى ما يعيده من هذه البيانات.

**دون تحديد صاحب المسودة — المسودات الثلاث**

```text
SELECT title FROM drafts;
```

العناوين في النتيجة: ملاحظات DNS · قائمة مراجعة Worker · خطة الإطلاق

لا تعرف القاعدة من سجّل دخوله إلى التطبيق. دون شرط WHERE، سيعيد هذا الاستعلام مسودة لينا أيضا. وترتيب النتائج هنا غير محدد.

**مسودات عمر — مسودتان**

```text
SELECT title FROM drafts
WHERE owner_id = ?;
-- Bound value: omar
```

العناوين في النتيجة: ملاحظات DNS · قائمة مراجعة Worker

يستخرج الخادم معرّف المستخدم من جلسة تسجيل الدخول التي تحقّق منها. لو سمحنا للعميل باختيار هذا المعرّف، لأمكنه طلب مسودات مستخدم آخر. ترتيب النتائج ما زال غير محدد.

**آخر مسودة عدّلها عمر — مسودة واحدة**

```text
SELECT title FROM drafts
WHERE owner_id = ?
ORDER BY updated_at DESC, id DESC
LIMIT 1;
-- Bound value: omar
```

العناوين في النتيجة: قائمة مراجعة Worker

في هذا المثال، «قائمة مراجعة Worker» هي الأحدث تعديلا. وعند تساوي وقت التعديل، يحسم المعرّف ترتيب الصفوف؛ لكنه لا يدل على ترتيب إنشائها.

هذه نتائج توضيحية لبيانات المثال، وليست استعلامات إلى قاعدة حية. تستخدم الواجهة البرمجية شرط المستخدم نفسه، وتعيد أحدث 20 مسودة كحد أقصى.

*تعيد القاعدة ما يطلبه الاستعلام. أضف شرط صاحب المسودة حتى لو كان تخمين معرّفها صعبا.*

ابدأ الملف `src/drafts.ts` بتعريف الموجّه ونوع البيانات التي سنقرأها، ثم أضف دالة وسيطة تضبط المستخدم التجريبي:

```ts title="src/drafts.ts · إعداد الموجّه"
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { readDraft, type DraftEnv } from "./draft-input";

export const drafts = new Hono<DraftEnv>();

type DraftRow = {
  id: string;
  title: string;
  text: string;
  language: "en" | "ar";
  wordsPerMinute: number;
  version: number;
  createdAt: number;
  updatedAt: number;
};

drafts.use("*", async (c, next) => {
  c.header("Cache-Control", "no-store");
  if (c.env.LOCAL_DRAFTS !== "true") {
    return c.json(
      { error: "Drafts are disabled until authentication is configured." },
      503,
    );
  }
  c.set("ownerId", "local-author");
  await next();
});

drafts.use("*", bodyLimit({
  maxSize: 64 * 1024,
  onError: (c) => c.json({ error: "Keep the request body within 64 KiB." }, 413),
}));
```

أضفنا `LOCAL_DRAFTS` إلى النوع حتى تبقى الشيفرة قابلة لفحص الأنواع إذا ولّدنا تعريفات الربط دون ملف `.dev.vars` المحلي، وبالتالي دون هذا المتغير.

نرفض جسم الطلب إذا تجاوز 64 KiB قبل أن تحلّله دالة المعالجة بصيغة JSON. وتطلب الترويسة `Cache-Control: no-store` من المتصفحات وذاكرات التخزين المؤقت المشتركة عدم حفظ الاستجابات. احتفظ بتسجيل معرّف الطلب ومعالج الأخطاء العام من الجزء الثالث، وتجنب تسجيل نصوص المسودات.

سنعيد استخدام `analyzeReading` للتحقق من النص واللغة وسرعة القراءة. وتضيف الدالة الوسيطة التالية التحقق من العنوان ورقم النسخة الاختياري. ضعها في `src/draft-input.ts` قبل المتابعة.

<details>
<summary>شيفرة التحقق من المدخلات: src/draft-input.ts</summary>

```ts title="src/draft-input.ts"
import { createMiddleware } from "hono/factory";
import { analyzeReading } from "./analyze";

export type DraftInput = {
  title: string;
  text: string;
  language: "en" | "ar";
  wordsPerMinute: number;
  version?: number;
};

export type DraftEnv = {
  Bindings: CloudflareBindings & { LOCAL_DRAFTS?: string };
  Variables: { ownerId: string; draft: DraftInput };
};

export const readDraft = createMiddleware<DraftEnv>(async (c, next) => {
  const mediaType = c.req.header("Content-Type")
    ?.split(";")[0]?.trim().toLowerCase();

  if (mediaType !== "application/json") {
    return c.json({ error: "Use Content-Type: application/json." }, 415);
  }

  let input: unknown;
  try {
    input = await c.req.json();
  } catch (error) {
    if (error instanceof SyntaxError) {
      return c.json({ error: "Send valid JSON." }, 400);
    }
    throw error;
  }

  if (!input || typeof input !== "object" || Array.isArray(input)) {
    return c.json({ error: "Send a JSON object." }, 400);
  }
  const data = input as Record<string, unknown>;
  const title = typeof data.title === "string" ? data.title.trim() : "";
  if (!title || title.length > 120) {
    return c.json({ error: "Use a title of 1–120 UTF-16 code units." }, 400);
  }

  const analysis = analyzeReading(data, 200);
  if (!analysis.ok) return c.json({ error: analysis.error }, 400);

  const version = data.version;
  if (version !== undefined && (
    typeof version !== "number" || !Number.isSafeInteger(version) || version < 1
  )) {
    return c.json({ error: "Use a positive integer version." }, 400);
  }

  c.set("draft", {
    title,
    text: data.text as string, // Validated by analyzeReading.
    language: analysis.value.language as DraftInput["language"],
    wordsPerMinute: analysis.value.wordsPerMinute,
    version,
  });
  await next();
});
```

</details>

سرعة القراءة الافتراضية هي 200 كلمة في الدقيقة. يبقى حد النص كما في المقال السابق: 10,000 وحدة ترميز UTF-16، وحد العنوان 120 وحدة. نستخدم `as` في موضعين لتحديد النوع، بعد أن تكون `analyzeReading` قد تحققت من القيم فعليا.

## حفظ مسودة

أضف معالج الطلب التالي إلى `src/drafts.ts`:

```ts title="src/drafts.ts · إنشاء مسودة"
drafts.post("/", readDraft, async (c) => {
  const draft = c.get("draft");
  const id = crypto.randomUUID();
  const now = Date.now();

  await c.env.DB.prepare(`
    INSERT INTO drafts
      (id, owner_id, title, text, language, words_per_minute, created_at, updated_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  `).bind(
    id, c.get("ownerId"), draft.title, draft.text,
    draft.language, draft.wordsPerMinute, now, now,
  ).run();

  return c.json({ id, version: 1 }, 201);
});
```

نكتب SQL في `prepare()`، ونمرر القيم إلى `bind()`. إذا احتوى العنوان على علامة اقتباس مفردة أو نص يشبه أوامر SQL، فسيُعامل كقيمة للعنوان. لا تُدخل قيم المستخدم مباشرة في نص الاستعلام. العلامات `?` تحل محل **قيم** فقط؛ أما أسماء الجداول واتجاه الترتيب فيجب تثبيتها أو اختيارها من قائمة مسموحة ومحددة مسبقا.

ننتظر انتهاء الإدراج باستخدام `await` قبل إعادة `201 Created`. بهذه الطريقة تعني استجابة النجاح أن الكتابة انتهت. تأجيلها إلى الخلفية باستخدام `waitUntil()` سيجعلنا نعلن نجاح الحفظ قبل معرفة نتيجته.

يولّد الخادم المعرّف والوقت، ويتجاهل أي `ownerId` أو `id` يرسله العميل في جسم الطلب. ولكل طلب إنشاء ناجح معرّف جديد؛ لذا فإن إرسال الطلب نفسه مرتين ينشئ مسودتين. وقد يحدث ذلك إذا نجحت الكتابة لكن الاستجابة لم تصل، فأعاد العميل المحاولة. قبل إضافة إعادة المحاولة تلقائيا، استخدم مفتاحا ثابتا لكل عملية إنشاء مقصودة، يسمى idempotency key، وافرض عدم تكراره في قاعدة البيانات.

## عرض أحدث المسودات وفتح إحداها

أضف هذين المعالجين إلى الملف نفسه:

```ts title="src/drafts.ts · عرض المسودات وفتحها"
drafts.get("/", async (c) => {
  const { results } = await c.env.DB.prepare(`
    SELECT id, title, language, version, updated_at AS updatedAt
    FROM drafts
    WHERE owner_id = ?
    ORDER BY updated_at DESC, id DESC
    LIMIT 20
  `).bind(c.get("ownerId")).all();

  return c.json({ drafts: results });
});

drafts.get("/:id", async (c) => {
  const draft = await c.env.DB.prepare(`
    SELECT id, title, text, language, words_per_minute AS wordsPerMinute,
           version, created_at AS createdAt, updated_at AS updatedAt
    FROM drafts
    WHERE id = ? AND owner_id = ?
  `).bind(c.req.param("id"), c.get("ownerId")).first<DraftRow>();

  if (!draft) return c.json({ error: "Draft not found." }, 404);
  return c.json(draft);
});
```

تعيد القائمة معلومات مختصرة عن أحدث 20 مسودة، ونطلب النص الكامل عند فتح مسودة بعينها. لعرض المكتبة كاملة، أضف الانتقال بين الصفحات باستخدام مؤشر، أو cursor، يعتمد على ترتيب `updated_at` و`id` نفسه. انتبه إلى أن تعديل مسودة قد ينقلها من صفحة إلى أخرى؛ فهذه قائمة تتغير مع التعديلات وليست لقطة ثابتة للبيانات.

تختلف نتيجة كل دالة: تعيد [`.all()`](https://developers.cloudflare.com/d1/worker-api/prepared-statements/) كائنا يحتوي على مصفوفة `results`، بينما تعيد `.first()` صفا واحدا أو `null`. النوع في `.first<DraftRow>()` يصف لـ TypeScript الشكل المتوقع للصف، لكنه لا يفحص بنية القاعدة أثناء التشغيل.

إذا كان المعرّف يخص مسودة مستخدم آخر، نعيد `404`، وهي الاستجابة نفسها لمعرّف غير موجود.

## تجربة أول عملية حفظ

استورد الموجّه في `src/index.ts` وأضفه بعد الدوال الوسيطة العامة الموجودة. احتفظ ببقية المسارات ومعالج الأخطاء:

```ts title="src/index.ts · الإضافات"
import { drafts } from "./drafts";

// After the existing global middleware:
app.route("/api/drafts", drafts);
```

يتضمن إعداد `assets.run_worker_first` من المقال السابق النمط `/api/*`، لذلك تصل طلبات المسودات إلى Hono. شغّل Worker محليا:

```bash
npx wrangler dev --ip 127.0.0.1 --port 8787
```

أنشئ مسودة:

```bash
curl -i http://127.0.0.1:8787/api/drafts \
  -H 'Content-Type: application/json' \
  --data '{
    "title": "ملاحظات لمقال D1",
    "text": "أريد العودة إلى هذه المسودة غدا.",
    "language": "ar",
    "wordsPerMinute": 200
  }'
```

يفترض أن تحصل على `201` وكائن JSON يحتوي على `id` و`"version": 1`. انسخ المعرّف إلى متغير في الطرفية، ثم اطلب قائمة المسودات وافتح المسودة:

```bash
DRAFT_ID="paste-the-returned-id-here"
curl http://127.0.0.1:8787/api/drafts
curl "http://127.0.0.1:8787/api/drafts/$DRAFT_ID"
```

أوقف Wrangler، وشغّله مجددا بالأمر نفسه، ثم أعد طلب `GET`. ستجد المسودة محفوظة. الاحتفاظ بالنص في متغير داخل Worker لا يضمن بقاءه بهذه الطريقة.

## عندما تفتح المسودة في تبويبين

تخيّل أنك فتحت المسودة في تبويبين. عدّلت الفقرة الأولى في أحدهما وحفظتها، ثم عدّلت العنوان في الآخر وضغطت حفظ. إذا كتبنا جميع الحقول دون شرط، فسيعيد التبويب الثاني النص القديم الذي ما زال يحتفظ به، ويمحو تعديل الفقرة.

نمنع ذلك بشرط واحد: **لا تحفظ التعديل إلا إذا كانت النسخة المخزّنة هي نفسها التي بدأ المستخدم تحريرها**.

**كيف نحافظ على التعديل الأحدث؟**

فتح التبويبان النسخة 1. عند الحفظ، تفحص القاعدة رقم النسخة داخل UPDATE، فيحدث التحقق والكتابة في تعليمة واحدة.

**1. فتح المسودة في التبويبين — النسخة المخزّنة: 1**

```text
A: version = 1
B: version = 1
```

يحتفظ كل تبويب برقم النسخة الذي وصله عند فتح المسودة. لم يحفظ أي منهما تعديلا بعد.

**2. التبويب الأول يحفظ تعديله — نجح الحفظ؛ أصبحت النسخة 2**

```text
UPDATE drafts SET …, version = version + 1
WHERE id = ? AND owner_id = ? AND version = 1;
```

يطابق الصف شرط التحديث. يُحفظ نص التبويب الأول، ويصبح رقم النسخة 2. ويتلقى التبويب هذا الرقم في الاستجابة ليستخدمه عند الحفظ التالي.

**3. التبويب الثاني يحاول الحفظ — تعارض 409؛ تبقى النسخة 2**

```text
UPDATE drafts SET …, version = version + 1
WHERE id = ? AND owner_id = ? AND version = 1;
```

لم يعد رقم النسخة يطابق الشرط. احتفظ بالنص غير المحفوظ في التبويب الثاني، واجلب المسودة الحالية، ودع المستخدم يقارن التعديلين. لا تعاود الحفظ تلقائيا برقم النسخة الجديد.

تسمى هذه الآلية optimistic concurrency: نسمح بالتحرير، ثم نرفض الحفظ إذا استند إلى نسخة قديمة. هي تكشف التعارض، أما دمج النصين فيحتاج إلى معالجة منفصلة.

*فحص رقم النسخة داخل UPDATE يمنع تبويبا يحتفظ بنص قديم من الكتابة فوق تعديل أحدث.*

أضف معالج التحديث:

```ts title="src/drafts.ts · تحديث مسودة"
drafts.put("/:id", readDraft, async (c) => {
  const draft = c.get("draft");
  if (draft.version === undefined) {
    return c.json({ error: "Include the version you loaded." }, 400);
  }

  const saved = await c.env.DB.prepare(`
    UPDATE drafts
    SET title = ?, text = ?, language = ?, words_per_minute = ?,
        updated_at = ?, version = version + 1
    WHERE id = ? AND owner_id = ? AND version = ?
    RETURNING id, version
  `).bind(
    draft.title, draft.text, draft.language, draft.wordsPerMinute,
    Date.now(), c.req.param("id"), c.get("ownerId"), draft.version,
  ).first<{ id: string; version: number }>();

  if (saved) return c.json(saved);

  const existing = await c.env.DB.prepare(`
    SELECT id FROM drafts WHERE id = ? AND owner_id = ?
  `).bind(c.req.param("id"), c.get("ownerId")).first();

  if (!existing) return c.json({ error: "Draft not found." }, 404);
  return c.json(
    { error: "This draft changed. Load it again before saving." },
    409,
  );
});
```

يستبدل طلب `PUT` الحقول القابلة للتحرير، لذلك يرسل العميل المسودة كاملة مع رقم النسخة التي لديه، ويستخدم رقم النسخة المعاد في الاستجابة عند الحفظ التالي. يتحقق `UPDATE` من صاحب المسودة ورقم نسختها في التعليمة نفسها التي تكتب التعديل. أما قراءة الرقم أولا ثم إرسال تحديث منفصل فتترك فرصة لطلب آخر أن يكتب بين العمليتين.

تعيد `RETURNING` رقم النسخة الجديد إذا طابق الصف الشروط. وإذا لم يحدث تحديث، نبحث مجددا ضمن مسودات المستخدم لنعرف هل حُذفت المسودة أم تغيّرت نسختها. هذا البحث يحدد الاستجابة المناسبة؛ أما منع الكتابة فوق النسخة الأحدث فقد حسمه شرط التحديث.

عند استقبال `409 Conflict`، احتفظ بنص المستخدم الذي لم يُحفظ. اجلب النسخة الحالية واعرض طريقة للمقارنة أو الجمع بين التعديلين. إعادة الحفظ تلقائيا برقم النسخة الأحدث ستلغي فائدة هذا الشرط.

أبقِ Worker قيد التشغيل، وحدّث المسودة التي أنشأتها قبل قليل:

```bash
curl -i -X PUT "http://127.0.0.1:8787/api/drafts/$DRAFT_ID" \
  -H 'Content-Type: application/json' \
  --data '{
    "title": "ملاحظات لمقال D1",
    "text": "يجب أن تبقى المسودة بعد إعادة التشغيل، وألا يضيع تعديلها بين تبويبين.",
    "language": "ar",
    "wordsPerMinute": 200,
    "version": 1
  }'
```

ينبغي أن تحتوي الاستجابة على `"version": 2`. أعد تنفيذ الأمر نفسه دون تغيير، وستحصل على `409` لأن النسخة `1` أصبحت قديمة. افتح المسودة وتأكد من أن محاولة الحفظ الثانية لم تغيّر النص المحفوظ.

## حذف مسودة

أضف المعالج الأخير إلى `src/drafts.ts`:

```ts title="src/drafts.ts · حذف مسودة"
drafts.delete("/:id", async (c) => {
  const result = await c.env.DB.prepare(`
    DELETE FROM drafts WHERE id = ? AND owner_id = ?
  `).bind(c.req.param("id"), c.get("ownerId")).run();

  if (result.meta.changes === 0) {
    return c.json({ error: "Draft not found." }, 404);
  }
  return c.body(null, 204);
});
```

تبيّن `meta.changes` ما إذا كان الحذف قد أثّر في صف. نتحقق هنا من صاحب المسودة فقط، ثم نحذف النسخة الحالية حتى لو عدّلها تبويب آخر منذ فتحها. إذا أردت أن يحذف المستخدم النسخة التي رآها فقط، فأضف شرط رقم النسخة إلى الحذف أيضا.

جرّب حالات الخطأ المتبقية قبل إضافة زر الحفظ إلى الصفحة:

| الطلب | النتيجة المتوقعة |
| --- | --- |
| JSON غير صالح | `400` |
| نص فارغ أو سرعة قراءة غير مقبولة | `400` |
| تحديث دون رقم نسخة | `400` |
| جسم طلب يتجاوز 64 KiB | `413` |
| معرّف مسودة تخص مستخدما آخر | `404` عند القراءة والتحديث والحذف |
| طلب لمسار المسودات مع غياب الإعداد المحلي | `503` |

احذف المسودة التجريبية بعد الانتهاء:

```bash
curl -i -X DELETE "http://127.0.0.1:8787/api/drafts/$DRAFT_ID"
```

ستحصل على `204` دون جسم للاستجابة، ثم `404` إذا حاولت فتحها. وعندما تضيف واجهة المتصفح لاحقا، اعرض العناوين والنصوص باستخدام خصائص النص أو قيم حقول النموذج، وتجنب إدخالها عبر `innerHTML`.

## فهرس يناسب الاستعلام

يطلب استعلام القائمة مسودات مستخدم واحد، مرتبة بوقت التعديل ثم المعرّف. صممنا الفهرس بالترتيب نفسه:

```sql
CREATE INDEX drafts_owner_updated
  ON drafts (owner_id, updated_at DESC, id DESC);
```

يتيح ذلك لـ SQLite الوصول إلى مدخلات المستخدم وقراءتها بالترتيب المطلوب. دون فهرس مناسب، قد يحتاج الاستعلام إلى فحص عدد كبير من الصفوف ثم ترتيب المطابق منها، حتى لو كانت النتيجة النهائية 20 صفا فقط.

جرّبت الاستعلام على 10,000 مسودة محلية موزعة بالتساوي على 100 مستخدم. دون فهرس المستخدم ووقت التعديل، سجّلت D1 قراءة 10,100 صف. بعد إضافة الفهرس، أصبح العدد 20. وفي الحالتين عاد الاستعلام بالصفوف نفسها وبالترتيب نفسه.

**قد نقرأ آلاف الصفوف لإعادة عشرين فقط**

قاعدة محلية فيها 10,000 مسودة موزعة على 100 مستخدم، لكل منهم 100 مسودة. يعيد الاستعلام في الحالتين أحدث 20 مسودة لمستخدم واحد.

**دون فهرس المستخدم ووقت التعديل — قراءة 10,100 صف**

```text
SCAN drafts
USE TEMP B-TREE FOR ORDER BY
```

تفحص القاعدة الجدول وتستخدم بنية مؤقتة لترتيب الصفوف المطابقة. يحد LIMIT 20 عدد النتائج، لكن خطة التنفيذ هذه ما زالت تحتاج إلى فحص الجدول.

**مع فهرس المستخدم ووقت التعديل — قراءة 20 صفا**

```text
SEARCH drafts USING INDEX drafts_owner_updated
(owner_id=?)
```

تصل القاعدة إلى مدخلات المستخدم بالترتيب المطلوب، وتتوقف بعد 20. النتيجة لم تتغير، لكن الوصول إليها احتاج إلى عمل أقل.

قياسات محلية لعدّاد meta.rows_read باستخدام Wrangler 4.161.0، بتاريخ 13 نوفمبر 2026. يستخدم الشريطان المقياس الخطي نفسه. الأرقام تخص قراءات هذه التجربة، ولا تتنبأ بزمن الاستجابة أو فاتورة التطبيق المنشور.

*تحدد LIMIT عدد الصفوف التي تصل إليك. أما العمل اللازم للوصول إليها فتحدده خطة تنفيذ الاستعلام.*

لاحظ أن عدّاد القراءة تجاوز عدد المسودات في التجربة، وأن خطة التنفيذ تستخدم بنية مؤقتة للترتيب. يقيس `meta.rows_read` عمل القراءة، وليس عدد معرّفات المسودات المختلفة. لمعرفة أثر الفهرس على زمن الاستجابة، قِس الطلب كاملا في التطبيق المنشور.

<details>
<summary>جرّب المقارنة بنفسك</summary>

احفظ الملف [index-lab.mjs](/examples/cloudflare-d1/index-lab.mjs) في مشروع `reading-api`، بجوار `wrangler.jsonc`، ثم شغّله:

```bash
node index-lab.mjs
```

يستخدم الملف نسخة Wrangler المثبتة لديك والربط `DB`. وينشئ، من خلال [`getPlatformProxy()`](https://developers.cloudflare.com/workers/wrangler/api/#getplatformproxy)، قاعدة محلية مؤقتة مع تعطيل حفظ الحالة والربط بالخدمات البعيدة. لا يلمس مسوداتك المحفوظة ولا قاعدة منشورة. يملأ الجدول ببيانات التجربة، وينفّذ الاستعلام قبل الفهرسة وبعدها، ويتأكد من تطابق النتائج، ثم يطبع العدّادات وخطتي التنفيذ. قد تختلف الأرقام مع إصدارات بيئة التشغيل أو عند استخدام بيانات أخرى.

</details>

ويمكنك فحص خطة الاستعلام في قاعدتك المحلية:

```bash
npx wrangler d1 execute reading-drafts --local --command \
  "EXPLAIN QUERY PLAN
   SELECT id, title, language, version, updated_at
   FROM drafts WHERE owner_id = 'local-author'
   ORDER BY updated_at DESC, id DESC LIMIT 20;"
```

ابحث عن `USING INDEX drafts_owner_updated`. يفترض أن يغني الفهرس عن فحص الجدول كاملا وعن الترتيب المؤقت. عند القياس في البيئة المنشورة، راقب `meta.rows_read` وقِس الوقت الذي يستغرقه استدعاء D1 كاملا حتى انتهاء `await`؛ إذ إن [مدة تنفيذ SQL في البيانات الوصفية](https://developers.cloudflare.com/d1/worker-api/return-object/) لا تشمل وقت الشبكة.

تحتسب D1 الاستخدام بحسب الصفوف المقروءة والمكتوبة، إضافة إلى التخزين. **عدد الصفوف في النتيجة لا يساوي بالضرورة عدد الصفوف التي قرأتها القاعدة.** قد تقلل الفهارس القراءات، لكنها تشغل مساحة، وقد تضيف عمليات كتابة محسوبة عند تحديثها. ابدأ بالاستعلامات التي يستخدمها تطبيقك فعلا، وأضف ما تحتاجه من فهارس ثم قِس أثرها. تشرح [وثائق التسعير](https://developers.cloudflare.com/d1/platform/pricing/) طريقة احتساب هذه العدّادات.

بحسب حدود نوفمبر 2026، تتيح الخطة المجانية قراءة 5 ملايين صف وكتابة 100,000 صف يوميا. عند بلوغ أحد الحدّين، تفشل الاستعلامات حتى تتجدد الحصة اليومية أو تنتقل إلى خطة مدفوعة. أما سعة التخزين المجانية الإجمالية، وهي 5 GB، فتتوزع على قواعد الحساب؛ إذ لا تتجاوز القاعدة الواحدة 500 MB. وفي خطة Workers Paid يصل حد القاعدة الواحدة إلى 10 GB. راجع [الأسعار](https://developers.cloudflare.com/d1/platform/pricing/) و[الحدود الحالية](https://developers.cloudflare.com/d1/platform/limits/) قبل الاعتماد على هذه الأرقام في التخطيط.

## فصل بيانات التجربة عن بيانات الموقع الفعلي

لم نربط المعرّف المؤقت بقاعدة بعيدة. عندما تكون جاهزا للتجربة على Cloudflare، أنشئ قاعدة فعلية واستخدم الإعدادات التي يعيدها Wrangler:

```bash
npx wrangler d1 create reading-drafts-preview
```

تبقى القاعدة مستقلة عن نسخ الشيفرة المنشورة. إذا ربطت نسخة معاينة بقاعدة الموقع الفعلي، فستتمكن شيفرة المعاينة من تغيير بيانات المستخدمين. خصص معرّفا لقاعدة المعاينة وآخر لقاعدة الموقع، وحدد وجهة ملفات تعديل البنية صراحة.

**تحقق من القاعدة التي تصل إليها شيفرتك**

يمكن تسمية الربط DB في جميع البيئات. المهم هو القاعدة التي يشير إليها في كل منها.

**التطوير المحلي — على جهازك**

يحفظ Wrangler حالة D1 داخل .wrangler. تبقى بيانات التجربة وتعديلات البنية على جهازك، ما لم تصدّرها وتستوردها في مكان آخر.

**المعاينة — قاعدة بعيدة مستقلة**

استخدم بيانات تجريبية ومعرّفا خاصا بقاعدة المعاينة. إذا استخدمت نسختا معاينة المعرّف نفسه، فستشتركان في البيانات وتعديلات البنية، حتى لو جاءت شيفرتهما من فرعين مختلفين.

**الموقع الفعلي — قاعدة بيانات المستخدمين**

اربطها بمعرّف قاعدة الموقع، وتحقق من جلسات المستخدمين، وراجع تغييرات البنية قبل تطبيقها. نشر نسخة جديدة من Worker لا ينشئ قاعدة فارغة.

يتيح رابط المعاينة تجربة نسخة من الشيفرة، لكنه لا يعزل تلقائيا البيانات التي تستطيع تلك النسخة الوصول إليها.

*اسم فرع Git لا يعزل البيانات. تحقق من معرّف القاعدة التي يشير إليها الربط قبل تطبيق أي تعديل على بنيتها عن بعد.*

يمكن، مثلا، تخصيص الملف `wrangler.preview-migrations.jsonc` لقاعدة المعاينة وحدها:

```jsonc title="wrangler.preview-migrations.jsonc"
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "d1_databases": [
    {
      "binding": "PREVIEW_DB",
      "database_name": "reading-drafts-preview",
      "database_id": "replace-with-the-real-preview-database-id",
      "migrations_dir": "migrations"
    }
  ]
}
```

وبذلك تكون وجهة الأمر الذي يطبّق تعديلات البنية محددة:

```bash
npx wrangler d1 migrations apply PREVIEW_DB --remote \
  --config wrangler.preview-migrations.jsonc
```

لنسخ المعاينة المرتبطة بالفروع، التي استخدمناها في الجزء الرابع، أضف ربط القاعدة إلى `previews.d1_databases` في إعدادات Worker، باسم `binding: "DB"` وبمعرّف قاعدة المعاينة نفسه. ملف التعديلات المنفصل يحدد وجهة تغييرات البنية فقط؛ أما ربط Worker بالقاعدة فيحتاج إلى هذا الإعداد أيضا. توضح [وثائق موارد المعاينة](https://developers.cloudflare.com/workers/previews/resources/) الإعداد الكامل. وتشترك نسخ المعاينة ذات المعرّف نفسه في تغييرات البنية؛ لذا استخدم قاعدة مستقلة لاختبار تغيير لا تتوافق معه بقية النسخ.

**قبل إتاحة المسودات عن بعد، استبدل المستخدم التجريبي بالتحقق من جلسة تسجيل دخول فعلية.** استخرج `ownerId` من الجلسة، وأعد `401` لمن لم يسجل دخوله، واحتفظ بشرط صاحب المسودة في الاستعلامات. وإذا اعتمدت الجلسة على ملفات الارتباط، فأضف الحماية المناسبة من CSRF للطلبات التي تغيّر البيانات. لا تنسخ `LOCAL_DRAFTS=true` إلى الإعدادات البعيدة لتجاوز استجابة `503`.

عند تغيير بنية قاعدة يستخدمها الزوار، احرص على توافق الشيفرة القديمة والجديدة معها أثناء النشر. يمكنك إضافة عمود يقبل `NULL`، ثم نشر الشيفرة التي تستخدمه، ثم ملء بياناته على دفعات مستقلة. يسهل التعامل مع هذا التسلسل أكثر من حذف عمود ما زالت النسخة القديمة تحتاجه. التراجع عن نشر Worker لا يتراجع عن التعديلات التي طبّقتها على قاعدة D1.

## عندما يكبر التطبيق

تعالج قاعدة D1 [استعلاما واحدا في كل مرة](https://developers.cloudflare.com/d1/platform/limits/)، وينطبق ذلك على كل نسخة قراءة على حدة. الاستعلام البطيء يشغلها مدة أطول ويؤخر العمل الذي يليه. اجعل الاستعلامات قصيرة، وراقب عدد الصفوف التي تقرؤها، وتجنب تحويل طلب HTTP واحد إلى عشرات الاستدعاءات المتتالية للقاعدة.

إذا احتاجت عملية واحدة إلى عدة كتابات مرتبطة، تنفّذ [`batch()`](https://developers.cloudflare.com/d1/worker-api/d1-database/) التعليمات المحضّرة بالتتابع ضمن معاملة واحدة، وتتراجع عنها جميعا إذا فشلت إحداها. أما استدعاءات `await` المنفصلة فلا تصبح معاملة واحدة لمجرد وجودها في الطلب نفسه. وانتبه إلى أن تحديثا لم يطابق أي صف يُعد ناجحا في SQL؛ لذلك لا تفترض أن اختلاف رقم النسخة سيُلغي بقية تعليمات الدفعة. التحديث الذي كتبناه هنا يحتاج إلى تعليمة واحدة فقط.

وللمستخدمين الموزعين جغرافيا، توفر D1 [نسخا مخصصة للقراءة](https://developers.cloudflare.com/d1/best-practices/read-replication/). فعّل الميزة على القاعدة واستخدم Sessions API لتوجيه القراءات إلى تلك النسخ. استعلامات الربط العادية في هذا المقال تستمر في الوصول إلى القاعدة الرئيسية، وتذهب جميع عمليات الكتابة إليها أيضا.

تحافظ جلسة D1 على تسلسل القراءات، بحيث لا تعيد قراءة لاحقة حالة أقدم من تلك التي رأتها قراءة سابقة. وهي لا تثبت هوية المستخدم ولا تجمع الاستعلامات في معاملة واحدة. مرّر قيمة `bookmark` الخاصة بها إلى الطلب التالي للحفاظ على هذا التسلسل. وإذا كان الطلب يجب أن يبدأ بأحدث بيانات القاعدة الرئيسية، فاستخدم `withSession("first-primary")`. إنشاء جلسة جديدة غير مقيّدة لكل استعلام لا يضمن أن يرى المستخدم ما كتبه للتو عندما يرسل طلب قراءة لاحقا.

أما استعادة البيانات، فتتوفر لها ميزة [Time Travel](https://developers.cloudflare.com/d1/reference/time-travel/) تلقائيا في قواعد D1 البعيدة: سبعة أيام للخطة المجانية و30 يوما للمدفوعة. تعيد الاستعادة القاعدة كلها إلى الحالة المختارة، فتشمل آثارها أيضا الكتابات السليمة التي حدثت بعدها. إذا أردت زر «تراجع» للمستخدم، فخزّن تاريخ مراجعات المسودة في التطبيق. واستخدم [تصدير SQL](https://developers.cloudflare.com/d1/best-practices/import-export-data/) للاحتفاظ بنسخة مستقلة، واختبر الاستعادة قبل أن تحتاج إليها.

أصبح بإمكانك حفظ مسودة، وإعادة تشغيل Worker، ثم فتحها من جديد. ولإتاحتها للمستخدمين، بقيت خطوات محددة: التحقق من الهوية، وربط الواجهة بالحفظ والفتح، ومعالجة تعارض التعديلات دون فقدان النص غير المحفوظ.

ما الذي تحتاجه مكتبة المسودات في تطبيقك: الاحتفاظ بالمراجعات، أم الوسوم، أم المشاركة؟ [أرسل لي الاستعلام الذي تحاول كتابته](/ar/contact)، أو شارك المقال مع زميل يضيف قاعدة بيانات إلى تطبيق Worker.
