التحقق على مستوى القاعدة والمعاملات — MongoDB
التحقق على مستوى القاعدة والمعاملات
لماذا لا يكفي مخطط Mongoose؟
مخطط Mongoose يعمل داخل تطبيقك فقط. سكربت صيانة، أو خدمة أخرى، أو updateOne بلا runValidators، أو شخص في Compass — كلها تكتب دون المرور به. لحماية البيانات نفسها، أضف تحققاً على مستوى المجموعة في MongoDB.
$jsonSchema validator
const productsValidator = {
$jsonSchema: {
bsonType: "object",
required: ["sku", "name", "price", "stock"],
properties: {
sku: { bsonType: "string", pattern: "^[A-Z0-9-]{3,20}$" },
name: { bsonType: "string", minLength: 1, maxLength: 120 },
price: { bsonType: ["double", "int", "decimal"], minimum: 0 },
stock: { bsonType: "int", minimum: 0 },
},
},
};
// await db.createCollection("products", { validator: productsValidator, validationLevel: "strict", validationAction: "error" });
// لمجموعة موجودة: db.command({ collMod: "products", validator: productsValidator })
console.log(productsValidator.$jsonSchema.required);- validationAction: "error" يرفض الكتابة المخالفة ("warn" يسجّلها فقط — مفيد عند إضافة تحقق لبيانات قديمة).
- validationLevel: "moderate" يطبق التحقق على المستندات الصالحة أصلاً فقط عند تعديلها.
- للمال بدقة: نوع Decimal128 بدل double.
الذرية على مستند واحد: مجانية
أي عملية على مستند واحد ذرية دائماً، حتى لو عدّلت عدة حقول ومصفوفات معاً:
// خصم المخزون فقط إن كان كافياً — عملية واحدة لا يتداخل معها طلب آخر
// const r = await products.updateOne({ _id: id, stock: { $gte: qty } }, { $inc: { stock: -qty } });
// if (r.modifiedCount === 0) throw new Error("نفد المخزون");
const atomicUpdate = { filter: { stock: { $gte: 2 } }, update: { $inc: { stock: -2 }, $push: { history: "sold 2" } } };
console.log(JSON.stringify(atomicUpdate));لذلك التصميم الجيد (تضمين ما يتغير معاً في مستند واحد) يغني عن أغلب المعاملات.
معاملات المستندات المتعددة
عندما يجب أن تنجح عمليات على عدة مستندات أو مجموعات معاً أو تفشل معاً (إنشاء طلب + خصم مخزون عدة منتجات + تسجيل دفعة):
// const session = await mongoose.startSession();
// try {
// await session.withTransaction(async () => {
// await Product.updateOne({ _id: p1, stock: { $gte: 1 } }, { $inc: { stock: -1 } }, { session });
// await Order.create([{ customer, items }], { session }); // لاحظ: مصفوفة مع session في create
// });
// } finally {
// await session.endSession();
// }
const rules = ["مرّر session لكل عملية", "withTransaction يعيد المحاولة عند الأخطاء المؤقتة", "لا عمليات خارجية بطيئة داخلها"];
console.log(rules.join(" | "));| القاعدة | السبب |
|---|---|
| مرّر { session } لكل عملية داخل المعاملة | عملية بلا session تُنفَّذ خارجها ولا تتراجع |
| استخدم withTransaction | تعيد المحاولة تلقائياً عند الأخطاء العابرة (TransientTransactionError) |
| معاملات قصيرة | حد زمني افتراضي 60 ثانية، وتحجز موارد |
| تتطلب Replica Set | Atlas كذلك دائماً؛ MongoDB المحلي المفرد يحتاج تشغيله كـ replica set |
| لا عمليات خارجية داخلها | لا تستدعِ بوابة الدفع أو ترسل بريداً داخل المعاملة: إن أُعيدت المحاولة تتكرر |
خطأ فشل ثم أُعيدت المحاولة؟
لأن withTransaction قد تشغّل الدالة أكثر من مرة، يجب أن تكون الدالة نقية من الآثار الجانبية الخارجية. نفّذ الإجراءات الخارجية بعد نجاح المعاملة (أو بنمط Outbox: سجّل "حدث" داخل المعاملة، وعملية منفصلة ترسله).
تمرين
أضف validator لمجموعة orders يفرض status من قائمة محددة و items مصفوفة غير فارغة. ثم اكتب دالة cancelOrder تعيد كميات العناصر للمخزون وتغيّر الحالة داخل معاملة واحدة.
// orderService.js — إنشاء طلب يخصم مخزون عدة منتجات بشكل ذري (npm install mongoose)
import mongoose from "mongoose";
const { Schema } = mongoose;
mongoose.set("sanitizeFilter", true);
const Product = mongoose.models.ProductTx ?? mongoose.model("ProductTx", new Schema({
sku: { type: String, required: true, unique: true },
name: String,
price: { type: Number, min: 0, required: true },
stock: { type: Number, min: 0, required: true },
}));
const Order = mongoose.models.OrderTx ?? mongoose.model("OrderTx", new Schema({
customer: { type: String, required: true },
items: [{ sku: String, quantity: { type: Number, min: 1 }, unitPrice: Number }],
total: Number,
status: { type: String, enum: ["paid", "cancelled"], default: "paid" },
}, { timestamps: true }));
export class OutOfStockError extends Error {}
export async function placeOrder(customer, requested) {
const session = await mongoose.startSession();
try {
let created;
await session.withTransaction(async () => {
const items = [];
for (const { sku, quantity } of requested) {
// ذري لكل منتج + session ← يتراجع مع المعاملة إن فشل منتج لاحق
const product = await Product.findOneAndUpdate(
{ sku: String(sku), stock: { $gte: quantity } },
{ $inc: { stock: -quantity } },
{ new: true, session }
);
if (!product) throw new OutOfStockError(`نفد المخزون: ${sku}`);
items.push({ sku: product.sku, quantity, unitPrice: product.price });
}
const total = items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
[created] = await Order.create([{ customer, items, total }], { session });
});
// الإجراءات الخارجية (بريد التأكيد) بعد نجاح المعاملة فقط
return created;
} finally {
await session.endSession();
}
}
// تجربة (تحتاج Replica Set — Atlas أو mongod --replSet)
await mongoose.connect(process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017/kodax_demo?replicaSet=rs0");
try {
await Product.deleteMany({ sku: { $in: ["PEN", "BAG"] } });
await Product.create([{ sku: "PEN", name: "قلم", price: 1.5, stock: 100 }, { sku: "BAG", name: "حقيبة", price: 25, stock: 1 }]);
const order = await placeOrder("sara", [{ sku: "PEN", quantity: 3 }, { sku: "BAG", quantity: 1 }]);
console.log("طلب ناجح، المجموع:", order.total);
await placeOrder("omar", [{ sku: "PEN", quantity: 2 }, { sku: "BAG", quantity: 1 }])
.catch((err) => console.log("رُفض:", err.message));
// القلم لم يُخصم للطلب المرفوض لأن المعاملة تراجعت
console.log("مخزون القلم:", (await Product.findOne({ sku: "PEN" })).stock); // 97
} finally {
await mongoose.disconnect();
}اختبر فهمك