تتجه حزمة التحليلات نحو جهاز المستخدم. فمع نضوج منظومة الـ lakehouse بسرعة — إذ صدر DuckLake 1.0 و Iceberg v3 هذا الصيف — يبرز فائز مفاجئ: قاعدة بيانات لا تحتاج إلى خادم على الإطلاق. تقوم DuckDB-WASM بترجمة محرك DuckDB التحليلي الكامل إلى WebAssembly، ما يعني أن متصفحات مستخدميك تستطيع فحص ملفات Parquet وربط الجداول وتشغيل دوال النوافذ (Window Functions) على ملايين الصفوف دون أن يصل طلب واحد إلى الخادم الخلفي.
في هذا الدرس ستبني لوحة تحليلات كاملة تعمل على جهاز المستخدم باستخدام Next.js 15 و DuckDB-WASM. ستستعلم عن ملفات Parquet بعيدة عبر HTTP، وستتيح للمستخدمين رفع ملفات CSV الخاصة بهم، وستعرض نتائج مجمّعة — كل ذلك بدون مسارات API، وبدون خوادم قواعد بيانات، ومع خصوصية كاملة، لأن البيانات المرفوعة لا تغادر جهاز المستخدم أبدًا.
لماذا نشغّل قاعدة بيانات داخل المتصفح؟
قبل كتابة الشيفرة، من المهم فهم متى تتألق هذه البنية:
- الخصوصية افتراضيًا. تُعالج بيانات المستخدم محليًا. بالنسبة لشركات منطقة الشرق الأوسط وشمال أفريقيا التي تتعامل مع بيانات مالية أو بيانات عملاء حساسة، يزيل هذا فئة كاملة من متطلبات الامتثال — لا يُنقل شيء، وبالتالي لا يوجد ما يجب تأمينه أثناء النقل أو التخزين على خوادمك.
- تكلفة خادم خلفي صفرية. العمل الثقيل (الفحص، الربط، التجميع) يعمل على معالج جهاز المستخدم. فاتورة الاستضافة لا تنمو مع حجم الاستعلامات.
- تفاعلية فورية. بعد التحميل الأولي للبيانات، يصبح كل تغيير في الفلاتر استعلامًا محليًا. ينخفض زمن الاستجابة من مئات المللي ثانية إلى بضع مللي ثوانٍ.
- لغة SQL حقيقية. تدعم DuckDB دوال النوافذ وتعابير CTE و PIVOT وتجميعات القوائم والاستعلام المباشر عن ملفات Parquet و CSV و JSON. هذه ليست لعبة — إنه المحرك نفسه الذي يشغّل خطوط بيانات إنتاجية.
المقابل: حزمة WASM تزن عدة ميغابايتات (تُحمَّل عند الحاجة وتُخزَّن مؤقتًا)، ويجب أن تتسع مجموعات البيانات في ذاكرة المتصفح — سقف عملي يتراوح بين بضع مئات من الميغابايتات وحوالي غيغابايتين حسب الجهاز. بالنسبة للوحات المعلومات والأدوات الداخلية وواجهات استكشاف البيانات، نادرًا ما يشكّل هذا السقف مشكلة.
المتطلبات الأساسية
قبل البدء، تأكد من توفر:
- Node.js 20+ مثبّت
- pnpm (أو npm/yarn) كمدير حزم
- معرفة أساسية بـ React وموجّه App Router في Next.js
- إلمام بلغة SQL (أوامر SELECT و GROUP BY و JOIN)
- محرر شيفرة (يُنصح بـ VS Code)
ما الذي ستبنيه
لوحة تحليلات من صفحة واحدة تقوم بما يلي:
- تشغيل DuckDB-WASM داخل Web Worker لإبقاء خيط الواجهة سريع الاستجابة
- الاستعلام عن مجموعة بيانات Parquet بعيدة عبر HTTP بطلبات النطاق (بدون تنزيل كامل)
- قبول رفع ملفات CSV وتسجيلها كجداول قابلة للاستعلام
- تشغيل استعلامات SQL مجمّعة وعرض النتائج في بطاقات إحصائية وجدول بيانات
- النشر كتطبيق Next.js ثابت بالكامل — قابل للنشر على أي CDN
الخطوة 1: إعداد المشروع
أنشئ مشروع Next.js 15 جديدًا مع TypeScript و Tailwind:
pnpm create next-app@latest duckdb-dashboard --typescript --tailwind --eslint --app --src-dir=false
cd duckdb-dashboardثبّت DuckDB-WASM و Apache Arrow (تُرجع DuckDB نتائج الاستعلامات كجداول Arrow):
pnpm add @duckdb/duckdb-wasm apache-arrowهذه هي قائمة الاعتماديات كاملة. لا ORM، ولا مشغّل قاعدة بيانات، ولا عميل API.
الخطوة 2: تهيئة DuckDB-WASM بنمط Singleton
تعمل DuckDB-WASM داخل Web Worker. إنشاؤها مكلف نسبيًا (جلب حزمة WASM وتشغيل الـ worker)، لذا تحتاج إلى نسخة واحدة بالضبط مشتركة عبر التطبيق كله. أنشئ lib/duckdb.ts:
// lib/duckdb.ts
import * as duckdb from "@duckdb/duckdb-wasm";
let dbPromise: Promise<duckdb.AsyncDuckDB> | null = null;
async function initDuckDB(): Promise<duckdb.AsyncDuckDB> {
// اختيار أفضل حزمة لهذا المتصفح (MVP أو EH أو COI)
const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
// لا يمكن إنشاء Worker من مصدر مختلف (cross-origin)،
// لذا نغلّف سكربت الـ CDN داخل عنوان Blob من المصدر نفسه
const workerUrl = URL.createObjectURL(
new Blob([`importScripts("${bundle.mainWorker}");`], {
type: "text/javascript",
})
);
const worker = new Worker(workerUrl);
const logger = new duckdb.ConsoleLogger(duckdb.LogLevel.WARNING);
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
URL.revokeObjectURL(workerUrl);
return db;
}
export function getDuckDB(): Promise<duckdb.AsyncDuckDB> {
if (!dbPromise) {
dbPromise = initDuckDB();
}
return dbPromise;
}بعض التفاصيل المهمة:
- تُرجع
getJsDelivrBundles()عناوين الملفات الثنائية WASM المستضافة على jsDelivr. وتختارselectBundle()أسرع نسخة يدعمها المتصفح الحالي — نسخة معالجة الاستثناءات للمتصفحات الحديثة، ونسخة MVP كخيار احتياطي. - حيلة عنوان Blob ضرورية لأن المتصفحات ترفض تشغيل Worker من عنوان cross-origin. تغليف سكربت الـ CDN داخل
importScriptsضمن Blob من المصدر نفسه يتجاوز هذا القيد بأناقة. - المتغير
dbPromiseعلى مستوى الوحدة يجعلها Singleton: الاستدعاءات المزدوجة في وضع Strict Mode في React والمكوّنات المتعددة التي تستدعيgetDuckDB()تتشارك جميعها نسخة واحدة.
إذا كنت تفضّل استضافة حزم WASM ذاتيًا بدل استخدام jsDelivr، انسخها من node_modules إلى مجلد public وابنِ خريطة حزم يدوية. الاستضافة الذاتية تتجنب الاعتماد على طرف ثالث في الإنتاج وتتيح لك ضبط ترويسات تخزين مؤقت طويلة بنفسك.
الخطوة 3: خطّاف React للاستعلامات
بعد ذلك، غلّف قاعدة البيانات في خطّاف (Hook) سهل الاستخدام. أنشئ hooks/useDuckDB.ts:
// hooks/useDuckDB.ts
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AsyncDuckDB, AsyncDuckDBConnection } from "@duckdb/duckdb-wasm";
import { getDuckDB } from "@/lib/duckdb";
export function useDuckDB() {
const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const dbRef = useRef<AsyncDuckDB | null>(null);
const connRef = useRef<AsyncDuckDBConnection | null>(null);
useEffect(() => {
let cancelled = false;
getDuckDB()
.then(async (db) => {
if (cancelled) return;
dbRef.current = db;
connRef.current = await db.connect();
setReady(true);
})
.catch((e) => setError(String(e)));
return () => {
cancelled = true;
};
}, []);
const query = useCallback(async (sql: string) => {
if (!connRef.current) throw new Error("DuckDB not ready");
const result = await connRef.current.query(sql);
// جدول Arrow -> كائنات JS بسيطة لتسهيل العرض
return result.toArray().map((row) => row.toJSON());
}, []);
return { ready, error, query, db: dbRef, conn: connRef };
}يحوّل المساعد query صفوف Arrow إلى كائنات JavaScript بسيطة عبر toJSON()، وهو أمر مريح للعرض. أما مع مجموعات نتائج ضخمة جدًا فمن الأفضل الاحتفاظ بجدول Arrow وقراءة الأعمدة مباشرة — فتخطيط Arrow العمودي أكثر كفاءة في الذاكرة بكثير — لكن لنتائج بحجم لوحة معلومات (من مئات إلى آلاف الصفوف)، تُبقي الكائنات البسيطة شيفرة React واضحة.
كل ما تلمسه DuckDB-WASM يجب أن يعمل على جهاز المستخدم. لا تستورد lib/duckdb.ts أبدًا من Server Component — توجيه "use client" على المكوّنات المستهلكة إلزامي، والاستيراد الديناميكي مع تعطيل ssr هو النمط الأكثر أمانًا للدمج على مستوى الصفحة.
الخطوة 4: الاستعلام عن ملفات Parquet بعيدة عبر HTTP
هنا تصبح DuckDB-WASM مبهرة حقًا. يمكنها الاستعلام عن ملف Parquet موجود على أي خادم HTTP يدعم CORS دون تنزيل الملف بأكمله. تقرأ DuckDB تذييل ملف Parquet، وتحدد مجموعات الصفوف والأعمدة التي تحتاجها، ثم تجلب نطاقات البايتات تلك فقط.
جرّب ذلك مع مجموعة بيانات عامة:
const rows = await query(`
SELECT
passenger_count,
COUNT(*) AS trips,
ROUND(AVG(fare_amount), 2) AS avg_fare
FROM 'https://blobs.duckdb.org/data/taxi_2019_04.parquet'
GROUP BY passenger_count
ORDER BY passenger_count
`);هذا تجميع كامل على ملف Parquet بعيد، يُنفَّذ داخل تبويب متصفح. يجلب الاستعلام الأول البيانات الوصفية والأعمدة المطلوبة؛ وتعيد الاستعلامات اللاحقة على الملف نفسه استخدام البيانات المخزّنة مؤقتًا.
بالنسبة للملفات التي تتحكم بها، هناك أمران مهمان:
- ترويسات CORS. يجب أن يرسل الخادم
Access-Control-Allow-Originلنطاقك وأن يسمح بترويسةRange. مخازن الكائنات مثل Cloudflare R2 و S3 و DigitalOcean Spaces تدعم ذلك عبر إعدادات CORS الخاصة بالحاوية. - دعم طلبات النطاق. كل شبكات CDN ومخازن الكائنات تقريبًا تدعم طلبات HTTP Range. بدونها، تعود DuckDB إلى التنزيل الكامل.
يمكنك أيضًا تسجيل العنوان صراحةً، ما يمنح الملف اسمًا سهلًا للاستخدام المتكرر:
import * as duckdb from "@duckdb/duckdb-wasm";
await db.registerFileURL(
"sales.parquet",
"https://cdn.example.com/data/sales.parquet",
duckdb.DuckDBDataProtocol.HTTP,
false
);
const summary = await query(`
SELECT region, SUM(amount) AS revenue
FROM 'sales.parquet'
GROUP BY region
ORDER BY revenue DESC
`);انشر مجموعات بيانات لوحاتك بصيغة Parquet بدل CSV أو JSON. صيغة Parquet عمودية ومضغوطة، لذا فإن استعلامًا يلمس 3 أعمدة من جدول يحوي 40 عمودًا يقرأ جزءًا يسيرًا من البايتات — غالبًا نقل أقل بـ 10 إلى 50 مرة من ملف CSV المكافئ.
الخطوة 5: السماح للمستخدمين برفع ملفات CSV
مسار البيانات الثاني هو الملفات التي يوفّرها المستخدم. تضع registerFileBuffer بايتات الملف في نظام الملفات الافتراضي لـ DuckDB، وتحوّله read_csv (مع الاكتشاف التلقائي للمخطط) إلى جدول. أنشئ components/CsvUploader.tsx:
// components/CsvUploader.tsx
"use client";
import { useState } from "react";
import { useDuckDB } from "@/hooks/useDuckDB";
export function CsvUploader({ onLoaded }: { onLoaded: (table: string) => void }) {
const { ready, db, conn } = useDuckDB();
const [status, setStatus] = useState<string>("");
async function handleFile(file: File) {
if (!db.current || !conn.current) return;
setStatus("جارٍ تحميل " + file.name + "...");
const buffer = new Uint8Array(await file.arrayBuffer());
await db.current.registerFileBuffer(file.name, buffer);
// إنشاء جدول حقيقي لتفادي إعادة تحليل CSV في كل استعلام
await conn.current.query(`
CREATE OR REPLACE TABLE uploaded AS
SELECT * FROM read_csv('${file.name}', header = true, auto_detect = true)
`);
const count = await conn.current.query(
`SELECT COUNT(*) AS n FROM uploaded`
);
setStatus(`تم تحميل ${count.toArray()[0].n} صفًا`);
onLoaded("uploaded");
}
return (
<div className="rounded-lg border border-dashed p-6">
<input
type="file"
accept=".csv"
disabled={!ready}
onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
/>
<p className="mt-2 text-sm text-gray-500">{status}</p>
</div>
);
}قراران تصميميان يستحقان الملاحظة:
- CREATE TABLE بدل الاستعلام عن الملف مباشرة. الاستعلام عبر
read_csv(...)يعيد تحليل ملف CSV في كل استعلام. أما تجسيده مرة واحدة في جدول DuckDB أصلي فيجعل كل الاستعلامات اللاحقة أسرع بشكل ملحوظ. - الملف لا يغادر المتصفح أبدًا. تقرؤه
arrayBuffer()إلى الذاكرة، وتسلّمهregisterFileBufferإلى نظام ملفات WASM. لا يوجد أي رفع — ميزة تسويقية حقيقية لأدوات تتعامل مع الرواتب أو الفواتير أو تصديرات بيانات العملاء.
الخطوة 6: بناء صفحة لوحة المعلومات
اجمع الآن القطع معًا. أنشئ app/dashboard/page.tsx بمكوّن عميل يشغّل استعلامات مجمّعة ويعرض النتائج:
// app/dashboard/page.tsx
"use client";
import { useEffect, useState } from "react";
import { useDuckDB } from "@/hooks/useDuckDB";
import { CsvUploader } from "@/components/CsvUploader";
type Row = Record<string, unknown>;
export default function DashboardPage() {
const { ready, error, query } = useDuckDB();
const [rows, setRows] = useState<Row[]>([]);
const [columns, setColumns] = useState<string[]>([]);
const [sql, setSql] = useState(
"SELECT * FROM 'https://blobs.duckdb.org/data/taxi_2019_04.parquet' LIMIT 20"
);
const [elapsed, setElapsed] = useState<number | null>(null);
async function run(sqlText: string) {
const start = performance.now();
try {
const result = (await query(sqlText)) as Row[];
setElapsed(Math.round(performance.now() - start));
setRows(result);
setColumns(result.length ? Object.keys(result[0]) : []);
} catch (e) {
alert(String(e));
}
}
useEffect(() => {
if (ready) run(sql);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready]);
if (error) return <p className="p-8 text-red-600">فشل DuckDB: {error}</p>;
if (!ready) return <p className="p-8 animate-pulse">جارٍ تشغيل DuckDB-WASM...</p>;
return (
<main className="mx-auto max-w-5xl p-8 space-y-6">
<h1 className="text-2xl font-bold">تحليلات داخل المتصفح</h1>
<CsvUploader onLoaded={() => run("SELECT * FROM uploaded LIMIT 50")} />
<textarea
className="w-full rounded border p-3 font-mono text-sm"
rows={4}
value={sql}
onChange={(e) => setSql(e.target.value)}
/>
<div className="flex items-center gap-4">
<button
onClick={() => run(sql)}
className="rounded bg-amber-500 px-4 py-2 font-medium text-white"
>
تشغيل الاستعلام
</button>
{elapsed !== null && (
<span className="text-sm text-gray-500">{elapsed} ms</span>
)}
</div>
<div className="overflow-x-auto rounded border">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
{columns.map((c) => (
<th key={c} className="px-3 py-2 text-left font-semibold">
{c}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t">
{columns.map((c) => (
<td key={c} className="px-3 py-2">
{String(r[c])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</main>
);
}شغّل خادم التطوير وافتح لوحة المعلومات:
pnpm dev
# افتح http://localhost:3000/dashboardأصبح لديك الآن مختبر SQL يعمل داخل المتصفح. اكتب أي استعلام SQL خاص بـ DuckDB — دوال النوافذ، PIVOT، الأمر summarize، تعابير القوائم — وسيُنفَّذ محليًا.
الخطوة 7: استعلامات تحليلية مفيدة
لكي تبدو اللوحة منتجًا حقيقيًا لا مجرد وحدة تحكم SQL، اربط استعلامات جاهزة ببطاقات إحصائية. إليك بعض الأنماط التي تُظهر القوة التحليلية لـ DuckDB:
تحليل فوري لملامح مجموعة البيانات — يمنحك الأمر SUMMARIZE في DuckDB القيم الدنيا والقصوى والمتوسط ونسبة القيم الفارغة والعدّ التقريبي للقيم الفريدة لكل عمود في جملة واحدة:
SUMMARIZE SELECT * FROM uploaded;تجميعات زمنية مع اقتطاع التاريخ:
SELECT
date_trunc('month', order_date) AS month,
COUNT(*) AS orders,
SUM(amount) AS revenue,
SUM(SUM(amount)) OVER (ORDER BY date_trunc('month', order_date)) AS cumulative
FROM uploaded
GROUP BY 1
ORDER BY 1;أفضل N لكل مجموعة باستخدام دوال النوافذ:
SELECT * FROM (
SELECT
region,
product,
SUM(amount) AS revenue,
ROW_NUMBER() OVER (PARTITION BY region ORDER BY SUM(amount) DESC) AS rank
FROM uploaded
GROUP BY region, product
) WHERE rank <= 3;كل استعلام من هذه الاستعلامات يعمل في بضع مللي ثوانٍ على مئات آلاف الصفوف — على حاسوب محمول، داخل تبويب متصفح.
اختبار التنفيذ
تحقق من عمل التدفق الكامل من البداية إلى النهاية:
- فحص الإقلاع: حمّل
/dashboardمع فتح أدوات المطوّر. يجب أن يُظهر تبويب الشبكة تحميل حزمة WASM مرة واحدة (حوالي 6 ميغابايت، ثم تُخزَّن مؤقتًا). تعرض الصفحة رسالة التشغيل ثم معاينة بيانات سيارات الأجرة. - ملف Parquet البعيد: شغّل استعلام التجميع من الخطوة 4. راقب تبويب الشبكة — يجب أن ترى طلبات نطاق (استجابات HTTP 206)، لا تنزيلًا كاملًا للملف.
- رفع CSV: أسقط أي ملف CSV. تأكد من ظهور عدد الصفوف ومن أن
SELECT * FROM uploaded LIMIT 5يعيد بياناتك. راجع تبويب الشبكة مجددًا: يجب ألا يوجد أي طلب رفع. - سلوك الذاكرة: حمّل ملف CSV أكبر (50-100 ميغابايت) وشغّل
SUMMARIZE. تزداد ذاكرة التبويب لكن الواجهة تبقى سريعة الاستجابة، لأن DuckDB تعمل في خيط الـ worker الخاص بها.
استكشاف الأخطاء وإصلاحها
خطأ "Failed to construct Worker" أو أخطاء CORS عند الإقلاع. على الأرجح أنك تنشئ الـ worker مباشرة من عنوان jsDelivr. استخدم تغليف عنوان Blob من الخطوة 2.
خطأ "window is not defined" أثناء البناء. هناك Server Component يستورد شيفرة DuckDB. تأكد من أن كل مستهلك يحمل التوجيه "use client" وفكّر في استيراد ديناميكي مع ssr: false للوحة بأكملها.
فشل استعلام Parquet البعيد بخطأ HTTP. خادم الاستضافة لا يرسل ترويسات CORS، أو يحجب ترويسة Range. اختبر أولًا بملف العينة المستضاف لدى DuckDB لعزل ما إذا كانت المشكلة في إعدادات الحاوية لديك.
الاستعلامات على ملفات CSV المرفوعة بطيئة. أنت تعيد قراءة ملف CSV في كل مرة. جسّده بأمر CREATE TABLE كما هو موضح في الخطوة 5.
قيم BigInt تسبب أخطاء في العرض. تعيد DuckDB الأعداد الصحيحة ذات 64 بت كـ BigInt، و JSON.stringify يفشل معها. حوّل النوع في SQL (باستخدام ::DOUBLE أو ::VARCHAR) أو حوّل بـ String() عند العرض، كما يفعل مكوّن الجدول أعلاه.
الخطوات التالية
- حافظ على البيانات بين الجلسات بدعم OPFS في DuckDB (فتح قاعدة بيانات بمسار
opfs://) لتبقى مجموعات البيانات المرفوعة بعد إعادة تحميل الصفحة. - أضف رسومًا بيانية — اربط نتائج الاستعلامات بمكتبة رسوم وأعد تشغيل الاستعلامات عند تغيير المستخدم للفلاتر؛ زمن الاستجابة المحلي يجعل التصفية المتقاطعة طبيعية تمامًا.
- استكشف زاوية الـ lakehouse — دليلنا الأخير عن DuckLake 1.0 و Iceberg v3 يعرض الجانب الخادمي المقابل لهذه الحزمة.
- قارن مع PGlite — لأحمال العمل المعاملاتية (لا التحليلية) داخل المتصفح، راجع درسنا عن PGlite، وهي Postgres مترجمة إلى WASM.
الخلاصة
لقد بنيت لوحة تحليلات بلا خادم فعليًا: تقلع DuckDB-WASM داخل Web Worker، وتفحص ملفات Parquet بعيدة بطلبات النطاق، وتستوعب ملفات CSV المستخدم دون رفع بايت واحد، وتجيب عن استعلامات SQL التحليلية في مللي ثوانٍ. بالنسبة للوحات المعلومات والأدوات الداخلية ومنتجات البيانات الحساسة للخصوصية، تحذف هذه البنية أغلى أجزاء الحزمة التقليدية — طبقة API وخادم قاعدة البيانات وعبء الامتثال المرتبط ببيانات مستخدمين لم تكن بحاجة إلى الاحتفاظ بها أصلًا.
الدرس الحقيقي هو التحوّل في النموذج الذهني: أصبح المتصفح اليوم هدف نشر مشروعًا لقواعد البيانات التحليلية. سلّم المحرك إلى مالك البيانات، وسيختفي معظم الخادم الخلفي لديك.