- لماذا يجب أن يكتب التطبيق محلياً أولاً، والمزامنة مهمة في الخلفية
- صندوق الصادر: كل عملية تُحفظ مع البيع في معاملة واحدة
- مفتاح التكرار: الخادم يقبل العملية نفسها مرتين دون أن يكررها
- قاعدة لكل نوع بيانات بدل قاعدة واحدة للتعارضات
المبدأ: اكتب محلياً أولاً
الخطأ الشائع أن يُبنى التطبيق كأنه موقع: الكاشير يضغط «بيع»، فيُرسل الطلب إلى الخادم، فإن فشل ظهرت رسالة خطأ. في هذا التصميم الإنترنت شرط للبيع.
المعمارية المعاكسة: الواجهة تقرأ وتكتب في قاعدة بيانات على الجهاز نفسه، والمزامنة عمل يجري في الخلفية. البيع يكتمل في أجزاء من الثانية سواء كان الإنترنت موجوداً أم لا، والخادم يعرف به بعد ثانية أو بعد ساعة. هذا ما يُسمّى offline-first، وهو الأساس لأي نقطة بيع تعمل في مكان يتقطع فيه الإنترنت.
صندوق الصادر
كل عملية تغيّر البيانات تُكتب في مكانين داخل معاملة واحدة: الجدول الذي تقرأ منه الواجهة، وصندوق الصادر (outbox) الذي ستُرسل منه إلى الخادم. المعاملة الواحدة تضمن ألا يوجد بيع بلا عملية صادرة، ولا عملية صادرة بلا بيع.
import { openDB } from 'idb';
export interface OutboxOp {
seq?: number; // ترتيب الإدخال، يولّده المتصفح
id: string; // معرّف يولّده الجهاز، وهو مفتاح التكرار عند الخادم
type: 'sale.create' | 'payment.add' | 'order.void';
payload: unknown;
deviceTime: string; // وقت الجهاز، للعرض فقط
}
export const dbp = openDB('pos', 1, {
upgrade(db) {
db.createObjectStore('outbox', { keyPath: 'seq', autoIncrement: true });
db.createObjectStore('sales', { keyPath: 'id' });
},
});
// البيع يُحفظ محلياً، والعملية تدخل الصادر، في معاملة واحدة
export async function recordSale(sale: { id: string; items: unknown[]; total: number }) {
const db = await dbp;
const tx = db.transaction(['sales', 'outbox'], 'readwrite');
await tx.objectStore('sales').put({ ...sale, synced: false });
await tx.objectStore('outbox').add({
id: crypto.randomUUID(),
type: 'sale.create',
payload: sale,
deviceTime: new Date().toISOString(),
} satisfies OutboxOp);
await tx.done;
}مفتاح الصندوق رقم تسلسلي يولّده IndexedDB، فتُقرأ العمليات بترتيب حدوثها. ولكل عملية معرّف UUID يولّده الجهاز، وهو ما سيعتمد عليه الخادم لكشف التكرار.
crypto.randomUUID() متاحة فقط في سياق آمن: HTTPS أو localhost. على عنوان http عادي داخل الشبكة المحلية ستجدها غير معرّفة، فاخدم التطبيق عبر HTTPS أو استعمل مكتبة لتوليد المعرّفات.مفتاح التكرار عند الخادم
الشبكة الضعيفة تخدعك بطريقة محددة: الطلب يصل إلى الخادم ويُنفَّذ، لكن الرد يضيع في الطريق. الجهاز لا يعرف ذلك فيعيد الإرسال. لو كان الخادم ساذجاً لسجّل البيع مرتين.
الحل جدول صغير بمفتاح أساسي هو معرّف العملية. نُدخل المعرّف ونطبّق العملية في معاملة واحدة: إن كان المعرّف جديداً طبّقناها، وإن كان موجوداً تجاهلناها، وفي الحالتين نخبر الجهاز أنها «مقبولة» فيحذفها من صندوقه.
// كل عملية تُنفَّذ مرة واحدة مهما تكرر إرسالها
app.post('/api/sync', async (req) => {
const accepted = [];
for (const op of req.body.ops) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rowCount } = await client.query(
`INSERT INTO processed_ops (op_id, device_id, received_at)
VALUES ($1, $2, now())
ON CONFLICT (op_id) DO NOTHING`,
[op.id, req.deviceId]);
if (rowCount === 1) await apply(client, op); // جديدة: نطبّقها
await client.query('COMMIT');
accepted.push(op.id); // جديدة أو مكررة: كلتاهما «مقبولة»
} catch (err) {
await client.query('ROLLBACK');
if (isPermanent(err)) { // بيانات خاطئة: لا تسدّ الطابور
await saveRejected(op, err);
accepted.push(op.id);
} else {
break; // خطأ مؤقت: نتوقف ونحاول لاحقاً بالترتيب
}
} finally {
client.release();
}
}
return { accepted };
});وفرّق بين نوعين من الأخطاء. خطأ مؤقت، كانقطاع قاعدة البيانات، يعني أن نتوقف ونعيد المحاولة لاحقاً بالترتيب نفسه. وخطأ دائم، كبيانات لا يمكن قبولها، لا يجوز أن يسدّ الطابور إلى الأبد: نحفظ العملية للمراجعة ونكمل.
حلقة المزامنة
الحلقة ترسل دفعات صغيرة بالترتيب، وتحذف من الصندوق ما قبله الخادم فقط. وعند الفشل تنتظر مدة تتضاعف حتى دقيقة، مع جزء عشوائي، حتى لا تهاجم عشرات الأجهزة الخادم في اللحظة نفسها حين يعود الإنترنت.
import { dbp, type OutboxOp } from './outbox';
const BATCH = 50;
let backoff = 2_000;
export async function syncLoop(): Promise<void> {
const db = await dbp;
const ops: OutboxOp[] = await db.getAll('outbox', null, BATCH); // بالترتيب
if (ops.length === 0) return schedule(10_000);
try {
const res = await fetch('/api/sync', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ops }),
});
if (!res.ok) throw new Error('sync ' + res.status);
const { accepted } = (await res.json()) as { accepted: string[] };
const done = new Set(accepted);
const tx = db.transaction('outbox', 'readwrite');
for (const op of ops) if (done.has(op.id)) await tx.store.delete(op.seq!);
await tx.done;
backoff = 2_000;
schedule(ops.length === BATCH ? 0 : 5_000); // بقي غيرها؟ أكمل فوراً
} catch {
backoff = Math.min(backoff * 2, 60_000);
schedule(backoff + Math.random() * 1_000); // تأخير متزايد مع عشوائية
}
}
function schedule(ms: number) {
setTimeout(syncLoop, ms);
}لا تعتمد على navigator.onLine: قد تقول إن الجهاز متصل بالراوتر بينما الراوتر نفسه بلا إنترنت. الطلب الحقيقي هو الاختبار الوحيد الصادق.
جرّبنا هذا الكود في المتصفح: ثلاث مبيعات سُجّلت والخادم «متوقف»، فبقيت الثلاث في الصندوق بعد الرد 503. ثم «عاد» الخادم، فوصلته المبيعات بترتيبها الأصلي وفرغ الصندوق.
التعارضات: لكل نوع بيانات قاعدة
لا توجد قاعدة واحدة تحل كل التعارضات. صنّف بياناتك أولاً:
| نوع البيانات | القاعدة |
|---|---|
| المبيعات والمدفوعات | أحداث تُضاف ولا تُعدَّل. البيع لا «يتعارض» مع بيع آخر، والإلغاء حدث جديد لا حذف. |
| المخزون والأرصدة | لا تُرسل من الجهاز «الرصيد الجديد»، بل الحدث، ويحسب الخادم الرصيد من الأحداث. |
| السجلات التي تُعدَّل (الأصناف، الأسعار) | رقم نسخة: التعديل يُقبل فقط إن لم يتغيّر السجل منذ قرأه الجهاز. |
| الإعدادات البسيطة | آخر تعديل يفوز، بوقت الخادم لا وقت الجهاز. |
-- تعديل صنف من جهازين: نقبل التعديل فقط إن لم يتغيّر منذ قرأه الجهاز
UPDATE menu_items
SET price = $1, version = version + 1, updated_at = now()
WHERE id = $2 AND version = $3;
-- إن لم يتغيّر أي صف فهذا تعارض: أعِد للجهاز النسخة الحالية ليقرر المستخدمالوقت والترقيم
ساعة الجهاز غير موثوقة: بطارية فرغت، أو توقيت ضُبط يدوياً. احفظ وقتين لكل عملية، وقت الجهاز ووقت استلام الخادم، واعتمد وقت الخادم في الحسابات الحساسة. وإن ظهر فرق كبير بينهما فهذه إشارة تستحق التنبيه.
ولا تعتمد على عدّاد تسلسلي في الجهاز لأرقام الطلبات، فجهازان بلا اتصال سيعطيان الرقم نفسه. أضف بادئة لكل جهاز، أو دع الخادم يعطي الرقم النهائي عند المزامنة.
ماذا يرى الكاشير؟
- مؤشراً صغيراً للحالة: متصل، أو يعمل بلا إنترنت، مع عدد العمليات التي تنتظر الإرسال.
- لا رسالة خطأ تمنعه من البيع حين ينقطع الاتصال.
- تنبيهاً واضحاً إن بقيت عمليات في الصندوق ساعات، لأن المشكلة عندها لم تعد انقطاعاً عابراً.
- ومن الجهة الأخرى، صاحب المطعم يرى في لوحته متى زامن كل جهاز آخر مرة.