- كل المقالات
- بناء التطبيقات على Cloudflare
- Cloudflare D1: حفظ المسودات وإدارتها بـ SQL
Cloudflare D1: حفظ المسودات وإدارتها بـ SQL
أضف حفظ المسودات إلى تطبيق Hono باستخدام Cloudflare D1، وتعلّم تصميم الجداول وتعديل بنيتها، وكتابة الاستعلامات، ومعالجة تعارض التعديلات، وفصل بيانات التجربة عن بيانات الموقع الفعلي.
الأداة التي بنيناها في الجزء الرابع تقدّر مدة قراءة النص، لكنها لا تحفظه. يكفي ذلك لتجربة سريعة. أما إذا كنت تكتب مقالا وتريد العودة إليه غدا، فستحتاج إلى مكان تحتفظ فيه بمسوداتك.
سنضيف هذه الإمكانية: حفظ عنوان ونص، وعرض أحدث المسودات، وفتح إحداها لمتابعة تحريرها. وسنعالج مشكلة تظهر بمجرد فتح المسودة في تبويبين: كيف نمنع أحدهما من محو التعديلات التي حفظها الآخر؟
سنستخدم Cloudflare D1، وهي خدمة قواعد بيانات SQL مبنية على SQLite وتتولى Cloudflare تشغيلها. سنبدأ بجدول واحد، ثم نبني الاستعلامات التي نحتاجها، مع الانتباه إلى صاحب كل مسودة وما يحدث عند تكرار طلب الحفظ.
للمتابعة، افتح مشروع reading-api المبني بـ Hono من الجزأين السابقين. سنحتفظ بالملف src/analyze.ts، والمسارات الحالية، وإعدادات تقديم الملفات الثابتة. سنبني هنا الواجهة البرمجية لحفظ المسودات ونختبرها محليا، ثم يصبح ربطها بأزرار الحفظ والفتح في الصفحة خطوة مستقلة وواضحة.
- متى نختار D1؟
- إنشاء القاعدة المحلية وجدول المسودات
- حفظ المسودات وتجربة أول طلب
- معالجة التعديلات من تبويبين
- الفهارس والتكلفة والاستعداد للنشر
متى نختار D1؟
أول ما نحتاجه من قائمة المسودات هو: اعرض مسودات هذا المستخدم، من الأحدث إلى الأقدم. وقد نريد لاحقا عرض المسودات العربية وحدها، أو إضافة وسوم، أو حساب عدد المسودات في كل شهر. يمكن التعبير عن هذه الطلبات مباشرة باستعلامات SQL.
يصل تطبيق Worker إلى القاعدة من خلال ربط، أو binding، مثل env.DB. لا نضع كلمة مرور للقاعدة في المتصفح، ولا ندير مجموعة اتصالات بها داخل Worker. يرسل المتصفح طلبا إلى واجهتنا البرمجية، وتختار شيفرة الخادم الاستعلام المناسب.
تختلف هذه الحاجة عن استخدامات خدمات التخزين الأخرى لدى Cloudflare:
| ما الذي تحتاجه؟ | خدمة مناسبة للبدء |
|---|---|
| مسودات مرتبطة بمستخدميها، مع البحث فيها وترتيبها | D1 |
| قيم نقرأها كثيرا بمفاتيح معروفة، ويمكن قبول تأخر ظهور تحديثاتها | Workers KV |
| ملفات مرفوعة، مثل الصور ومستندات PDF | R2 |
| تنسيق العمل بين المشاركين في جلسة تحرير مباشر | Durable Objects |
| وصل Workers بقاعدة PostgreSQL أو MySQL موجودة | Hyperdrive |
تستخدم D1 صيغة SQL الخاصة بـ SQLite، ولها حدود تشغيل يجب مراعاتها. لكل قاعدة نسخة رئيسية تستقبل عمليات الكتابة. لذلك قد يصل الطلب إلى Worker قريب من المستخدم، ثم يحتاج إلى الاتصال بقاعدة في منطقة أخرى. سنعود إلى نسخ القراءة لاحقا.
يكفينا الآن جدول واحد. نخزّن النص الأصلي ولغته وسرعة القراءة المختارة، ونحسب تقدير مدة القراءة عند الحاجة باستخدام analyzeReading. لو خزّنا عدد الكلمات أيضا، لوجب علينا تحديثه مع النص ومراجعته كلما تغيرت طريقة الحساب.
إنشاء القاعدة المحلية وجدول المسودات
أضف الربط التالي إلى المستوى الأعلى من wrangler.jsonc. احتفظ بالإعدادات الحالية، بما فيها assets وvars:
{
"d1_databases": [
{
"binding": "DB",
"database_name": "reading-drafts",
"database_id": "00000000-0000-0000-0000-000000000000"
}
]
}
المعرّف المؤلف من أصفار قيمة مؤقتة للتجربة المحلية. يستطيع Wrangler تشغيل D1 محليا دون إنشاء قاعدة على Cloudflare، ويحفظ حالتها داخل .wrangler. تبقى المسودات بعد إعادة تشغيل خادم التطوير، لكنها تُحذف إذا حذفت هذا المجلد. أبقِ .wrangler/ خارج Git.
نحتاج أيضا إلى مستخدم ثابت للتجربة. أضف السطر التالي إلى .dev.vars، وتأكد من استثناء هذا الملف من Git:
LOCAL_DRAFTS="true"
يفعّل هذا الإعداد مستخدما تجريبيا واحدا؛ وهو لا يتحقق من هوية من يرسل الطلب. استخدم نصوصا تجريبية، وشغّل الخادم على 127.0.0.1، ولا تضف هذا الإعداد إلى أي بيئة منشورة. عند غيابه تعيد مسارات المسودات الاستجابة 503، بينما تبقى واجهة حساب مدة القراءة متاحة.
ولّد تعريفات الأنواع للربط الجديد، ثم أنشئ أول ملف لتعديل بنية القاعدة:
npx wrangler types --env-interface CloudflareBindings
npx wrangler d1 migrations create reading-drafts create_drafts
ينشئ Wrangler ملف SQL مرقّما داخل 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، ثم يزداد مع كل تحديث ناجح. سنستخدمه بعد قليل لاكتشاف محاولات الحفظ المبنية على نسخة قديمة.
تتحقق القاعدة من اللغة المسموحة ومن نطاق سرعة القراءة. وسنتحقق في الواجهة البرمجية من العنوان والنص أيضا، حتى نعيد رسالة مفهومة للمستخدم قبل محاولة كتابة بيانات غير صالحة.
طبّق الملف على القاعدة المحلية:
npx wrangler d1 migrations apply reading-drafts --local
نسجّل كل تغيير في بنية القاعدة في ملف migration، ويتابع Wrangler الملفات التي سبق تطبيقها. حين تحتاج إلى تغيير جديد، أنشئ ملفا جديدا بدلا من تعديل ملف نُفّذ في بيئة أخرى. احفظ هذه الملفات في Git مع الشيفرة التي تعتمد عليها.
تحديد صاحب المسودة مسؤولية الخادم
معرفة معرّف مسودة لا تمنح صاحب الطلب حق قراءتها أو تعديلها.
لهذا نضيف معرّف صاحبها إلى شرط WHERE في كل استعلام قراءة أو تحديث أو حذف. في التطبيق المنشور، نستخرج هذا المعرّف من جلسة تسجيل دخول تحققنا من صحتها. لو أخذنا ownerId من JSON أو من رابط الطلب، لأتحنا للمتصل اختيار مسودات أي مستخدم يريد.
تتبّع الاستعلام
أي مسودات سيعرضها التطبيق؟
لنفترض أن لدينا ثلاث مسودات: «ملاحظات DNS» و«قائمة مراجعة Worker» لعمر، و«خطة الإطلاق» للينا. اختر استعلاما لترى ما يعيده من هذه البيانات.
- دون تحديد صاحب المسودةالمسودات الثلاث
SELECT title FROM drafts;العناوين في النتيجةملاحظات DNS · قائمة مراجعة Worker · خطة الإطلاق
لا تعرف القاعدة من سجّل دخوله إلى التطبيق. دون شرط WHERE، سيعيد هذا الاستعلام مسودة لينا أيضا. وترتيب النتائج هنا غير محدد.
- مسودات عمرمسودتان
SELECT title FROM drafts WHERE owner_id = ?; -- Bound value: omarالعناوين في النتيجةملاحظات DNS · قائمة مراجعة Worker
يستخرج الخادم معرّف المستخدم من جلسة تسجيل الدخول التي تحقّق منها. لو سمحنا للعميل باختيار هذا المعرّف، لأمكنه طلب مسودات مستخدم آخر. ترتيب النتائج ما زال غير محدد.
- آخر مسودة عدّلها عمرمسودة واحدة
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 بتعريف الموجّه ونوع البيانات التي سنقرأها، ثم أضف دالة وسيطة تضبط المستخدم التجريبي:
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 قبل المتابعة.
شيفرة التحقق من المدخلات: 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();
});سرعة القراءة الافتراضية هي 200 كلمة في الدقيقة. يبقى حد النص كما في المقال السابق: 10,000 وحدة ترميز UTF-16، وحد العنوان 120 وحدة. نستخدم as في موضعين لتحديد النوع، بعد أن تكون analyzeReading قد تحققت من القيم فعليا.
حفظ مسودة
أضف معالج الطلب التالي إلى 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، وافرض عدم تكراره في قاعدة البيانات.
عرض أحدث المسودات وفتح إحداها
أضف هذين المعالجين إلى الملف نفسه:
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() كائنا يحتوي على مصفوفة results، بينما تعيد .first() صفا واحدا أو null. النوع في .first<DraftRow>() يصف لـ TypeScript الشكل المتوقع للصف، لكنه لا يفحص بنية القاعدة أثناء التشغيل.
إذا كان المعرّف يخص مسودة مستخدم آخر، نعيد 404، وهي الاستجابة نفسها لمعرّف غير موجود.
تجربة أول عملية حفظ
استورد الموجّه في src/index.ts وأضفه بعد الدوال الوسيطة العامة الموجودة. احتفظ ببقية المسارات ومعالج الأخطاء:
import { drafts } from "./drafts";
// After the existing global middleware:
app.route("/api/drafts", drafts);
يتضمن إعداد assets.run_worker_first من المقال السابق النمط /api/*، لذلك تصل طلبات المسودات إلى Hono. شغّل Worker محليا:
npx wrangler dev --ip 127.0.0.1 --port 8787
أنشئ مسودة:
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. انسخ المعرّف إلى متغير في الطرفية، ثم اطلب قائمة المسودات وافتح المسودة:
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
A: version = 1 B: version = 1يحتفظ كل تبويب برقم النسخة الذي وصله عند فتح المسودة. لم يحفظ أي منهما تعديلا بعد.
- 2. التبويب الأول يحفظ تعديلهنجح الحفظ؛ أصبحت النسخة 2
UPDATE drafts SET …, version = version + 1 WHERE id = ? AND owner_id = ? AND version = 1;يطابق الصف شرط التحديث. يُحفظ نص التبويب الأول، ويصبح رقم النسخة 2. ويتلقى التبويب هذا الرقم في الاستجابة ليستخدمه عند الحفظ التالي.
- 3. التبويب الثاني يحاول الحفظتعارض 409؛ تبقى النسخة 2
UPDATE drafts SET …, version = version + 1 WHERE id = ? AND owner_id = ? AND version = 1;لم يعد رقم النسخة يطابق الشرط. احتفظ بالنص غير المحفوظ في التبويب الثاني، واجلب المسودة الحالية، ودع المستخدم يقارن التعديلين. لا تعاود الحفظ تلقائيا برقم النسخة الجديد.
تسمى هذه الآلية optimistic concurrency: نسمح بالتحرير، ثم نرفض الحفظ إذا استند إلى نسخة قديمة. هي تكشف التعارض، أما دمج النصين فيحتاج إلى معالجة منفصلة.
أضف معالج التحديث:
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 قيد التشغيل، وحدّث المسودة التي أنشأتها قبل قليل:
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:
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 |
احذف المسودة التجريبية بعد الانتهاء:
curl -i -X DELETE "http://127.0.0.1:8787/api/drafts/$DRAFT_ID"
ستحصل على 204 دون جسم للاستجابة، ثم 404 إذا حاولت فتحها. وعندما تضيف واجهة المتصفح لاحقا، اعرض العناوين والنصوص باستخدام خصائص النص أو قيم حقول النموذج، وتجنب إدخالها عبر innerHTML.
فهرس يناسب الاستعلام
يطلب استعلام القائمة مسودات مستخدم واحد، مرتبة بوقت التعديل ثم المعرّف. صممنا الفهرس بالترتيب نفسه:
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 صف
SCAN drafts USE TEMP B-TREE FOR ORDER BYتفحص القاعدة الجدول وتستخدم بنية مؤقتة لترتيب الصفوف المطابقة. يحد LIMIT 20 عدد النتائج، لكن خطة التنفيذ هذه ما زالت تحتاج إلى فحص الجدول.
- مع فهرس المستخدم ووقت التعديلقراءة 20 صفا
SEARCH drafts USING INDEX drafts_owner_updated (owner_id=?)تصل القاعدة إلى مدخلات المستخدم بالترتيب المطلوب، وتتوقف بعد 20. النتيجة لم تتغير، لكن الوصول إليها احتاج إلى عمل أقل.
قياسات محلية لعدّاد meta.rows_read باستخدام Wrangler 4.161.0، بتاريخ 13 نوفمبر 2026. يستخدم الشريطان المقياس الخطي نفسه. الأرقام تخص قراءات هذه التجربة، ولا تتنبأ بزمن الاستجابة أو فاتورة التطبيق المنشور.
لاحظ أن عدّاد القراءة تجاوز عدد المسودات في التجربة، وأن خطة التنفيذ تستخدم بنية مؤقتة للترتيب. يقيس meta.rows_read عمل القراءة، وليس عدد معرّفات المسودات المختلفة. لمعرفة أثر الفهرس على زمن الاستجابة، قِس الطلب كاملا في التطبيق المنشور.
جرّب المقارنة بنفسك
احفظ الملف index-lab.mjs في مشروع reading-api، بجوار wrangler.jsonc، ثم شغّله:
node index-lab.mjsيستخدم الملف نسخة Wrangler المثبتة لديك والربط DB. وينشئ، من خلال getPlatformProxy()، قاعدة محلية مؤقتة مع تعطيل حفظ الحالة والربط بالخدمات البعيدة. لا يلمس مسوداتك المحفوظة ولا قاعدة منشورة. يملأ الجدول ببيانات التجربة، وينفّذ الاستعلام قبل الفهرسة وبعدها، ويتأكد من تطابق النتائج، ثم يطبع العدّادات وخطتي التنفيذ. قد تختلف الأرقام مع إصدارات بيئة التشغيل أو عند استخدام بيانات أخرى.
ويمكنك فحص خطة الاستعلام في قاعدتك المحلية:
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 في البيانات الوصفية لا تشمل وقت الشبكة.
تحتسب D1 الاستخدام بحسب الصفوف المقروءة والمكتوبة، إضافة إلى التخزين. عدد الصفوف في النتيجة لا يساوي بالضرورة عدد الصفوف التي قرأتها القاعدة. قد تقلل الفهارس القراءات، لكنها تشغل مساحة، وقد تضيف عمليات كتابة محسوبة عند تحديثها. ابدأ بالاستعلامات التي يستخدمها تطبيقك فعلا، وأضف ما تحتاجه من فهارس ثم قِس أثرها. تشرح وثائق التسعير طريقة احتساب هذه العدّادات.
بحسب حدود نوفمبر 2026، تتيح الخطة المجانية قراءة 5 ملايين صف وكتابة 100,000 صف يوميا. عند بلوغ أحد الحدّين، تفشل الاستعلامات حتى تتجدد الحصة اليومية أو تنتقل إلى خطة مدفوعة. أما سعة التخزين المجانية الإجمالية، وهي 5 GB، فتتوزع على قواعد الحساب؛ إذ لا تتجاوز القاعدة الواحدة 500 MB. وفي خطة Workers Paid يصل حد القاعدة الواحدة إلى 10 GB. راجع الأسعار والحدود الحالية قبل الاعتماد على هذه الأرقام في التخطيط.
فصل بيانات التجربة عن بيانات الموقع الفعلي
لم نربط المعرّف المؤقت بقاعدة بعيدة. عندما تكون جاهزا للتجربة على Cloudflare، أنشئ قاعدة فعلية واستخدم الإعدادات التي يعيدها Wrangler:
npx wrangler d1 create reading-drafts-preview
تبقى القاعدة مستقلة عن نسخ الشيفرة المنشورة. إذا ربطت نسخة معاينة بقاعدة الموقع الفعلي، فستتمكن شيفرة المعاينة من تغيير بيانات المستخدمين. خصص معرّفا لقاعدة المعاينة وآخر لقاعدة الموقع، وحدد وجهة ملفات تعديل البنية صراحة.
أين تُحفظ البيانات؟
تحقق من القاعدة التي تصل إليها شيفرتك
يمكن تسمية الربط DB في جميع البيئات. المهم هو القاعدة التي يشير إليها في كل منها.
- التطوير المحليعلى جهازك
يحفظ Wrangler حالة D1 داخل .wrangler. تبقى بيانات التجربة وتعديلات البنية على جهازك، ما لم تصدّرها وتستوردها في مكان آخر.
- المعاينةقاعدة بعيدة مستقلة
استخدم بيانات تجريبية ومعرّفا خاصا بقاعدة المعاينة. إذا استخدمت نسختا معاينة المعرّف نفسه، فستشتركان في البيانات وتعديلات البنية، حتى لو جاءت شيفرتهما من فرعين مختلفين.
- الموقع الفعليقاعدة بيانات المستخدمين
اربطها بمعرّف قاعدة الموقع، وتحقق من جلسات المستخدمين، وراجع تغييرات البنية قبل تطبيقها. نشر نسخة جديدة من Worker لا ينشئ قاعدة فارغة.
يتيح رابط المعاينة تجربة نسخة من الشيفرة، لكنه لا يعزل تلقائيا البيانات التي تستطيع تلك النسخة الوصول إليها.
يمكن، مثلا، تخصيص الملف 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"
}
]
}
وبذلك تكون وجهة الأمر الذي يطبّق تعديلات البنية محددة:
npx wrangler d1 migrations apply PREVIEW_DB --remote \
--config wrangler.preview-migrations.jsonc
لنسخ المعاينة المرتبطة بالفروع، التي استخدمناها في الجزء الرابع، أضف ربط القاعدة إلى previews.d1_databases في إعدادات Worker، باسم binding: "DB" وبمعرّف قاعدة المعاينة نفسه. ملف التعديلات المنفصل يحدد وجهة تغييرات البنية فقط؛ أما ربط Worker بالقاعدة فيحتاج إلى هذا الإعداد أيضا. توضح وثائق موارد المعاينة الإعداد الكامل. وتشترك نسخ المعاينة ذات المعرّف نفسه في تغييرات البنية؛ لذا استخدم قاعدة مستقلة لاختبار تغيير لا تتوافق معه بقية النسخ.
قبل إتاحة المسودات عن بعد، استبدل المستخدم التجريبي بالتحقق من جلسة تسجيل دخول فعلية. استخرج ownerId من الجلسة، وأعد 401 لمن لم يسجل دخوله، واحتفظ بشرط صاحب المسودة في الاستعلامات. وإذا اعتمدت الجلسة على ملفات الارتباط، فأضف الحماية المناسبة من CSRF للطلبات التي تغيّر البيانات. لا تنسخ LOCAL_DRAFTS=true إلى الإعدادات البعيدة لتجاوز استجابة 503.
عند تغيير بنية قاعدة يستخدمها الزوار، احرص على توافق الشيفرة القديمة والجديدة معها أثناء النشر. يمكنك إضافة عمود يقبل NULL، ثم نشر الشيفرة التي تستخدمه، ثم ملء بياناته على دفعات مستقلة. يسهل التعامل مع هذا التسلسل أكثر من حذف عمود ما زالت النسخة القديمة تحتاجه. التراجع عن نشر Worker لا يتراجع عن التعديلات التي طبّقتها على قاعدة D1.
عندما يكبر التطبيق
تعالج قاعدة D1 استعلاما واحدا في كل مرة، وينطبق ذلك على كل نسخة قراءة على حدة. الاستعلام البطيء يشغلها مدة أطول ويؤخر العمل الذي يليه. اجعل الاستعلامات قصيرة، وراقب عدد الصفوف التي تقرؤها، وتجنب تحويل طلب HTTP واحد إلى عشرات الاستدعاءات المتتالية للقاعدة.
إذا احتاجت عملية واحدة إلى عدة كتابات مرتبطة، تنفّذ batch() التعليمات المحضّرة بالتتابع ضمن معاملة واحدة، وتتراجع عنها جميعا إذا فشلت إحداها. أما استدعاءات await المنفصلة فلا تصبح معاملة واحدة لمجرد وجودها في الطلب نفسه. وانتبه إلى أن تحديثا لم يطابق أي صف يُعد ناجحا في SQL؛ لذلك لا تفترض أن اختلاف رقم النسخة سيُلغي بقية تعليمات الدفعة. التحديث الذي كتبناه هنا يحتاج إلى تعليمة واحدة فقط.
وللمستخدمين الموزعين جغرافيا، توفر D1 نسخا مخصصة للقراءة. فعّل الميزة على القاعدة واستخدم Sessions API لتوجيه القراءات إلى تلك النسخ. استعلامات الربط العادية في هذا المقال تستمر في الوصول إلى القاعدة الرئيسية، وتذهب جميع عمليات الكتابة إليها أيضا.
تحافظ جلسة D1 على تسلسل القراءات، بحيث لا تعيد قراءة لاحقة حالة أقدم من تلك التي رأتها قراءة سابقة. وهي لا تثبت هوية المستخدم ولا تجمع الاستعلامات في معاملة واحدة. مرّر قيمة bookmark الخاصة بها إلى الطلب التالي للحفاظ على هذا التسلسل. وإذا كان الطلب يجب أن يبدأ بأحدث بيانات القاعدة الرئيسية، فاستخدم withSession("first-primary"). إنشاء جلسة جديدة غير مقيّدة لكل استعلام لا يضمن أن يرى المستخدم ما كتبه للتو عندما يرسل طلب قراءة لاحقا.
أما استعادة البيانات، فتتوفر لها ميزة Time Travel تلقائيا في قواعد D1 البعيدة: سبعة أيام للخطة المجانية و30 يوما للمدفوعة. تعيد الاستعادة القاعدة كلها إلى الحالة المختارة، فتشمل آثارها أيضا الكتابات السليمة التي حدثت بعدها. إذا أردت زر «تراجع» للمستخدم، فخزّن تاريخ مراجعات المسودة في التطبيق. واستخدم تصدير SQL للاحتفاظ بنسخة مستقلة، واختبر الاستعادة قبل أن تحتاج إليها.
أصبح بإمكانك حفظ مسودة، وإعادة تشغيل Worker، ثم فتحها من جديد. ولإتاحتها للمستخدمين، بقيت خطوات محددة: التحقق من الهوية، وربط الواجهة بالحفظ والفتح، ومعالجة تعارض التعديلات دون فقدان النص غير المحفوظ.
ما الذي تحتاجه مكتبة المسودات في تطبيقك: الاحتفاظ بالمراجعات، أم الوسوم، أم المشاركة؟ أرسل لي الاستعلام الذي تحاول كتابته، أو شارك المقال مع زميل يضيف قاعدة بيانات إلى تطبيق Worker.