Drizzle ORM + pg-boss دليل تعليمي عن المعاملات الذرية (2026)
٣١ مايو ٢٠٢٦
قم بتوصيل Drizzle ORM 0.45.2 بـ pg-boss 12.18.2 من خلال محول fromDrizzle المدمج في pg-boss بحيث تشترك عملية إدخال صف وإرسال الوظيفة المطابقة في معاملة Postgres واحدة. يتم الاعتماد (Commit) معاً، أو التراجع (Roll back) معاً. أنواع TypeScript من البداية للنهاية، سياسات إعادة المحاولة، طوابير الرسائل المهملة (dead-letter queues)، جداول cron، وقابل للتشغيل مقابل postgres:18-alpine.
ملخص
نمط "إرسال بريد إلكتروني بعد إنشاء صف" يُكتب دائماً تقريباً باستدعاءين غير مرتبطين: INSERT INTO orders ... ثم boss.send('email', ...). إذا توقفت العملية بينهما، فستقوم بشحن طلب بدون بريد إلكتروني - أو والأسوأ من ذلك، إرسال بريد إلكتروني لطلب تم التراجع عنه. يوفر pg-boss 12 محول fromDrizzle(tx, sql) الذي يوجه إدخال الوظيفة عبر كائن معاملة Drizzle الخاص بك، بحيث تشترك كلتا الكتابتين في معاملة Postgres واحدة. يتم الاعتماد معاً، والتراجع معاً، بدون كود ربط إضافي. هذا المنشور يبني النظام الكامل في 8 خطوات.
ما ستتعلمه
- كيف يجعل
pg-boss.fromDrizzleإدخال الطابور يشترك في معاملة مع كتابات Drizzle الخاصة بك - كيفية هيكلة مشروع Postgres 18 + pg-boss 12 + Drizzle 0.45.2 مع أنواع TypeScript شاملة
- كيفية إرسال وظيفة بشكل ذري (atomically) مع إدخال صف بحيث يتم اعتمادهما معاً أو التراجع عنهما معاً
- كيفية تعيين حدود إعادة المحاولة، والتراجع الأسي (exponential backoff)، وطابور الرسائل المهملة لكل طابور
- كيفية جدولة الوظائف المتكررة باستخدام تعبيرات cron ومنطقة زمنية
- كيفية كتابة عامل (worker) يرسل وظائف تابعة داخل معاملته الخاصة
- كيفية التحقق من سلوك التراجع باستخدام معاملة تفشل عمداً
- كيفية إغلاق الطابور والمجمع (pool) بشكل نظيف عند استلام
SIGTERM
المتطلبات الأساسية
تحتاج إلى Node.js 24 (إصدار LTS النشط اعتباراً من مايو 2026، مدعوم حتى أبريل 2028 - يعمل Node 22 أيضاً لأن pg-boss 12 يتطلب Node ≥22.12.0)1. تحتاج إلى Docker (أو طريقة أخرى لتشغيل Postgres 18) ومحطة طرفية (terminal) مع توفر npm و psql محلياً وهو أمر مفيد ولكنه غير مطلوب. يُفترض الإلمام بأساسيات TypeScript و async/await.
تم اختبار جميع الأوامر على macOS و Linux. يجب على مستخدمي Windows تشغيل كل شيء من WSL2 بحيث تشترك Docker و Node و psql في مساحة مستخدم Linux واحدة.
لماذا تهم "الجدولة الذرية" (atomic enqueue)
الوظائف الخلفية التي تعمل بعد الكتابة في قاعدة البيانات موجودة في كل مكان: إيصالات الطلبات، رسائل الترحيب بعد التسجيل، خطافات الويب (webhooks) التابعة، تحديثات فهرس البحث، وبدايات التنفيذ. النمط الساذج يبدو كالتالي:
await db.insert(orders).values(input); // commit 1
await boss.send('order.receipt-email', { ... }); // commit 2
هناك أربعة أوضاع للفشل بين commit 1 و commit 2 تؤدي لتلف بياناتك:
- تعطل العملية بعد commit 1 ← الطلب موجود، لا يوجد بريد إلكتروني. العميل دفع، ولم يحصل على شيء.
- تعطل العملية بعد commit 2، قبل الرد على مستدعي HTTP ← يعيد المستدعي المحاولة، فتقوم بإنشاء طلب ثانٍ، ويحصل العميل على بريدين إلكترونيين.
- رفض
boss.sendبسبب خطأ اتصال Postgres عابر ← الطلب موجود، لا يوجد بريد إلكتروني. - خطوة لاحقة في نفس المعالج تطرح خطأ ← تقوم بعمل
try/catchوالتراجع عن الصف، لكن الوظيفة موجودة بالفعل في الطابور، ويرسل العامل بريداً إلكترونياً لعميل حول طلب لم يعد موجوداً.
الحل هو وضع كلتا الكتابتين في نفس معاملة Postgres. يقبل boss.send الخاص بـ pg-boss خيار { db } الذي يتجاوز مكان إدخال الوظيفة. إذا وجهت ذلك الـ db إلى طريقة execute الخاصة بمعاملة Drizzle، فسيستقر صف الوظيفة داخل نفس المعاملة مثل إدخال الصف.2
الخطوة 1 — هيكلة المشروع
أنشئ دليلاً جديداً وقم بتثبيت الإصدارات المحددة:
mkdir orders-API && cd orders-API
npm init -y
npm pkg set type=module
npm install --save-exact pg-boss@12.18.2 drizzle-orm@0.45.2 pg@8.21.0
npm install --save-exact --save-dev TypeScript@6.0.3 \
drizzle-kit@0.31.10 tsx@4.22.3 dotenv@17.4.2 \
@types/pg@8.20.0 @types/node@22.19.17
علامة --save-exact ضرورية: npm install pg-boss@12.18.2 بدونها يكتب ^12.18.2، لذا فإن أي npm install مستقبلي قد يقوم بالترقية إلى 12.19.x ويغير مخطط خيارات الطابور بهدوء. قم بقفل مستوى التصحيح (patch level).3
أضف ملف tsconfig.json مع أكثر الخيارات صرامة التي يمكنك تحملها. المجموعة أدناه هي الأساس للإنتاج المستخدم في بقية المنشور:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
يسمح لك allowImportingTsExtensions: true باستيراد الملفات كـ './schema.ts'. يقوم Node 22.18+ و Node 24 بتجريد الأنواع محلياً، ويقبل tsx نفس الصيغة، لذا يعمل نفس المصدر في كليهما4.
أضف ملف .env مع سلسلة الاتصال التي ستوجه كل شيء إليها:
echo 'DATABASE_URL=postgres://orders:orders@127.0.0.1:5432/orders' > .env
echo '.env' >> .gitignore
echo 'node_modules' >> .gitignore
الخطوة 2 — تشغيل Postgres 18
استخدم صورة postgres:18-alpine الرسمية - يشير الوسم العائم حالياً إلى 18-alpine3.23 (قاعدة Alpine 3.23)5:
Docker run --rm -d --name orders-pg \
-e POSTGRES_USER=orders \
-e POSTGRES_PASSWORD=orders \
-e POSTGRES_DB=orders \
-p 5432:5432 \
postgres:18-alpine
تحقق من أنه يعمل:
Docker exec orders-pg pg_isready -U orders -d orders
# المتوقع: /var/run/postgresql:5432 - accepting connections
سيقوم pg-boss بإنشاء المخطط الخاص به (pgboss افتراضياً - سنقوم بقفله صراحة في الخطوة 4) وتشغيل عمليات الهجرة الخاصة به عند البدء الأول. لست بحاجة لإنشاء أي شيء مسبقاً.
الخطوة 3 — تعريف مخطط Drizzle
أنشئ src/db/schema.ts مع جدول الأعمال الوحيد الذي يهتم به هذا البرنامج التعليمي - جدول orders مع تعداد (enum) للحالة:
// src/db/schema.ts
import {
pgTable,
uuid,
text,
integer,
timestamp,
index,
pgEnum,
} from 'drizzle-orm/pg-core';
export const orderStatus = pgEnum('order_status', [
'pending',
'paid',
'fulfilled',
'failed',
]);
export const orders = pgTable(
'orders',
{
id: uuid('id').defaultRandom().primaryKey(),
customerEmail: text('customer_email').notNull(),
amountCents: integer('amount_cents').notNull(),
currency: text('currency').notNull().default('USD'),
status: orderStatus('status').notNull().default('pending'),
createdAt: timestamp('created_at', { withTimezone: true )
.notNull()
.defaultNow(),
},
(t) => [
index('orders_email_idx').on(t.customerEmail),
index('orders_status_idx').on(t.status),
],
);
export type Order = typeof orders.$inferSelect;
export type OrderInsert = typeof orders.$inferInsert;
يوفر لك $inferSelect و $inferInsert أشكال صفوف مكتوبة مجاناً - بدون ملف DTO منفصل6.
قم بتوصيل عميل Drizzle بـ pg.Pool في src/db/client.ts:
// src/db/client.ts
import 'dotenv/config';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from './schema.ts';
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is required');
export const pool = new Pool({
connectionString: url,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
pool.on('error', (err) => {
console.error('pg pool error', err);
});
export const db = drizzle({ client: pool, schema, casing: 'snake_case' });
export type Database = typeof db;
خيار casing: 'snake_case' هو إعداد افتراضي للأمان. يمرر المخطط أعلاه أسماء الأعمدة الصريحة (text('customer_email'))، لذا ليس لحالة الأحرف (casing) أي تأثير هناك - ولكن أي عمود تضيفه لاحقاً بدون اسم صريح (text() وحده) سيظل يُصدر كـ snake_case في SQL الناتج، مما يطابق بقية جداولك.
أضف تكوين Drizzle Kit وقم بإنشاء الهجرة:
// drizzle.config.ts
import 'dotenv/config';
import type { Config } from 'drizzle-kit';
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is required');
export default {
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: { url ,
} satisfies Config;
أضف البرامج النصية (scripts) إلى package.json:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"typecheck": "tsc --noEmit",
"dev": "tsx watch src/index.ts"
}
}
ثم ادفع المخطط:
npm run db:push
# [✓] Pulling schema from database...
# [✓] Changes applied
الخطوة 4 — إعداد pg-boss
أنشئ src/queue.ts:
// src/queue.ts
import 'dotenv/config';
import { PgBoss } from 'pg-boss';
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is required');
export const boss = new PgBoss({
connectionString: url,
schema: 'pgboss',
max: 5,
monitorIntervalSeconds: 30,
application_name: 'orders-API',
});
boss.on('error', (err: Error) => {
console.error('pg-boss error', err);
});
export const QUEUES = {
receiptEmail: 'order.receipt-email',
fulfillment: 'order.fulfillment',
} as const;
هناك شيئان غير بديهيين هنا:
- الاستيراد هو
import { PgBoss } from 'pg-boss'- مسمى، وليس افتراضياً. pg-boss 12 هو ESM نقي (Node ≥22.12.0) ويتم تصدير الفئة كاستيراد مسمى فقط7. - يتدفق
application_nameإلىpg_stat_activity.application_nameفي Postgres، مما يجعل من السهل العثور على اتصالات pg-boss (SELECT pid, query FROM pg_stat_activity WHERE application_name = 'orders-API';) عند الفحص. قم بتعيينه صراحة على الرغم من أنه اختياري.
عندما تستدعي boss.start() لأول مرة، يقوم pg-boss بإنشاء مخطط pgboss وتشغيل عمليات الهجرة الخاصة به. إصدار المخطط في pg-boss 12.18.2 هو 30 (يمكنك التحقق في وقت التشغيل باستخدام boss.schemaVersion() أو عبر CLI: npx pg-boss version).
الخطوة 5 — وظيفة إنشاء الطلب الذرية
هذا هو جوهر البرنامج التعليمي. أنشئ src/orders/create.ts:
// src/orders/create.ts
import { sql from 'drizzle-orm';
import { fromDrizzle from 'pg-boss';
import { db from '../db/client.ts';
import { orders, type OrderInsert from '../db/schema.ts';
import { boss, QUEUES from ;
export interface CreateOrderInput {
customerEmail: string;
amountCents: number;
currency?: string;
}
export interface ReceiptEmailJob {
orderId: string;
customerEmail: string;
amountCents: number;
currency: string;
}
export async function createOrderAtomic(
input: CreateOrderInput,
): Promise<{ orderId: string; receiptJobId: string | null > {
return db.transaction(async (tx) => {
const insert: OrderInsert = {
customerEmail: input.customerEmail,
amountCents: input.amountCents,
currency: input.currency ?? 'USD',
status: 'pending',
};
const [row] = await tx
.insert(orders)
.values(insert)
.returning({ id: orders.id );
if (!row) throw new Error('order insert returned no row');
const receiptJobId = await boss.send(
QUEUES.receiptEmail,
{
orderId: row.id,
customerEmail: input.customerEmail,
amountCents: input.amountCents,
currency: input.currency ?? 'USD',
} satisfies ReceiptEmailJob,
{ db: fromDrizzle(tx, sql) ,
);
return { orderId: row.id, receiptJobId ;
});
}
ثلاثة أشياء يجب استيعابها:
- تفتح
db.transaction(async (tx) => {...})معاملة Postgres واحدة. كل استعلام مقابلtxيعمل على نفس الاتصال الفعلي حتى يتم حل رد الاتصال (callback) أو طرح خطأ.
boss.send(name, data, { db: fromDrizzle(tx, sql) }) هو المكان الذي يحدث فيه السحر. خيار db يتجاوز تجمع الاتصالات الافتراضي لـ pg-boss باستخدام محول IDatabase الذي يتم إرجاعه بواسطة fromDrizzle. يقوم المحول executeSql(text, values) بإعادة كتابة العناصر النائبة $1/$2 إلى تنسيق template-tag الخاص بـ Drizzle ويستدعي tx.execute(sql(strings, ...reordered)) — نفس المعاملة، نفس الاتصال، ونفس حدود الحفظ (commit)8.satisfies في حمولة الوظيفة (job payload) تفرض الهيكل دون توسيعه: إذا قمت لاحقًا بإضافة حقل مطلوب إلى ReceiptEmailJob، فستفشل عملية فحص النوع (type-checking) في كل مكان يتم استدعاؤه فيه حتى تقوم بإصلاحه.إذا حدث أي خطأ داخل رد الاستدعاء (callback)، يقوم Drizzle بالتراجع (rollback) تلقائيًا — ويتراجع إرسال الوظيفة معه. لا توجد وظيفة "معلقة" في الطابور لطلب غير موجود.
الخطوة 6 — تهيئة عمليات إعادة المحاولة وطابور الرسائل المهملة (DLQ)
ميزانية إعادة المحاولة الافتراضية هي 2 بدون تأخير، وهو أمر جيد لمشاريع "hello-world" ولكنه غير مسؤول في بيئة الإنتاج9. قم بتجاوزها لكل طابور. أنشئ src/orders/retry.ts:
// src/orders/retry.ts
import { boss } from '../queue.ts';
export async function configureRetries(): Promise<void> {
await boss.createQueue('order.receipt-email', {
policy: 'standard',
retryLimit: 5,
retryDelay: 30,
retryBackoff: true,
expireInSeconds: 60,
retentionSeconds: 60 * 60 * 24 * 14,
deleteAfterSeconds: 60 * 60 * 24 * 7,
deadLetter: 'order.receipt-email.dlq',
});
await boss.createQueue('order.receipt-email.dlq');
}
أسماء الحقول التي تستحق الحفظ لأنها خطأ شائع في أوراق البيانات:
| الحقل | الافتراضي | ملاحظات |
|---|---|---|
retryLimit | 2 | أقصى عدد من المحاولات بعد التشغيل الأولي |
retryDelay | 0 | الثواني بين المحاولات |
retryBackoff | false | عند تفعيلها، تطبق تراجعًا أسيًا (exponential backoff) على retryDelay |
expireInSeconds | 900 | أقصى عدد من الثواني في حالة active قبل اعتبار الوظيفة فاشلة |
retentionSeconds | 1209600 (14 يومًا) | مدة الاحتفاظ بالوظائف غير المكتملة |
deleteAfterSeconds | 604800 (7 أيام) | مدة الاحتفاظ بالوظائف المكتملة |
deadLetter | لا يوجد | اسم طابور آخر يستقبل الحمولة بعد الفشل النهائي |
retryLimit: 5 + retryDelay: 30 + retryBackoff: true يعطي تراجعًا مبسطًا 30 * 2^n ثانية بين المحاولات — تقريبًا 30 ثانية، 60 ثانية، 120 ثانية، 240 ثانية، 480 ثانية، مع بعض التذبذب (jitter) — ثم تستقر الحمولة في order.receipt-email.dlq للمراجعة البشرية. يوثق مصدر pg-boss الصيغة كـ Math.min(retryDelayMax, retryDelay * (2 ** Math.min(16, retryCount) / 2 + 2 * Math.min(16, retryCount) / 2 * Math.random())).
لا يوجد حقل retentionDays في الطابور. محاولة تمرير واحد ستؤدي إلى خطأ TypeScript مقابل المعامل المكتوب Omit<Queue, 'name'>؛ استخدم حقول *Seconds المذكورة أعلاه بدلاً من ذلك10. (لاحظ أن pg-boss يوفر حقلاً واحدًا بصيغة *Days — وهو warningRetentionDays في خيارات MaintenanceOptions العالمية — ولكن هذا يتحكم في سجل تحذيرات BAM، وليس الاحتفاظ بالوظائف.)
الخطوة 7 — العمال (Workers) الذين يرسلون الوظائف التابعة بشكل ذري (atomically)
يعمل العمال داخل معاملاتهم الخاصة. عندما يتفرع العامل إلى وظائف أخرى، فأنت تريد نفس دلالات التراجع. أنشئ src/orders/fulfill.ts:
// src/orders/fulfill.ts
import { eq, sql } from 'drizzle-orm';
import { fromDrizzle } from 'pg-boss';
import { db } from '../db/client.ts';
import { orders } from '../db/schema.ts';
import { boss, QUEUES } from '../queue.ts';
export interface FulfillmentJob {
orderId: string;
}
export async function registerFulfillmentWorker(): Promise<void> {
await boss.work<FulfillmentJob, void>(
QUEUES.fulfillment,
{
batchSize: 1,
pollingIntervalSeconds: 1,
},
async (jobs) => {
for (const job of jobs) {
const { orderId } = job.data;
await db.transaction(async (tx) => {
const [row] = await tx
.update(orders)
.set({ status: 'fulfilled' })
.where(eq(orders.id, orderId))
.returning({ id: orders.id });
if (!row) {
throw new Error(`order ${orderId} not found`);
}
await boss.send(
QUEUES.receiptEmail,
{ orderId, type: 'fulfilled' },
{ db: fromDrizzle(tx, sql) },
);
});
}
},
);
}
export async function scheduleNightlyReconcile(): Promise<void> {
await boss.schedule(
'reconcile.orders',
'0 2 * * *',
{},
{ tz: 'UTC' },
);
await boss.work('reconcile.orders', async () => {
console.log('[reconcile] running nightly check at', new Date().toISOString());
});
}
ثلاث ملاحظات هامة:
boss.work<ReqData, ResData>(name, options, handler)— يتلقى المعالج مصفوفة منJob<ReqData>، وليس وظيفة واحدة.batchSize: 1تجعل طول المصفوفة دائمًا 1، وهو ما يريده معظم الناس فعليًا عند استخدام API.- يفتح المعالج معاملة Drizzle. في الداخل، يتشارك كل من تحديث الصف وإرسال البريد الإلكتروني للإيصال في تلك المعاملة. إذا فشل إرسال الإيصال بعد تحديث الصف، يتراجع تحديث الصف — ويقوم الفحص التالي بالتقاط نفس وظيفة التنفيذ (fulfillment) وإعادة المحاولة بشكل نظيف.
boss.schedule(name, cron, data, { tz })يسجل جدول cron. الخيارtzيكون افتراضيًا'UTC'؛ قم بتمرير منطقة IANA مختلفة (مثل'America/New_York') فقط عندما تحتاج فعليًا إلى جدولة تعتمد على التوقيت المحلي.11
الخطوة 8 — إثبات عمل التراجع (Rollback)
دالة تجريبية تتعمد إلقاء خطأ بعد إرسال الوظيفة، حتى تتمكن من رؤية الطابور يظل فارغًا عندما تتراجع المعاملة. أنشئ src/orders/rollback-demo.ts:
// src/orders/rollback-demo.ts
import { sql } from 'drizzle-orm';
import { fromDrizzle } from 'pg-boss';
import { db } from '../db/client.ts';
import { orders } from '../db/schema.ts';
import { boss, QUEUES } from '../queue.ts';
export class FraudCheckFailed extends Error {
override readonly name = 'FraudCheckFailed';
}
export async function createOrderWithFraudCheck(input: {
customerEmail: string;
amountCents: number;
}): Promise<{ orderId: string } | { rolledBack: true }> {
try {
return await db.transaction(async (tx) => {
const [row] = await tx
.insert(orders)
.values({
customerEmail: input.customerEmail,
amountCents: input.amountCents,
status: 'pending',
})
.returning({ id: orders.id });
if (!row) throw new Error('insert returned no row');
await boss.send(
QUEUES.receiptEmail,
{ orderId: row.id, customerEmail: input.customerEmail },
{ db: fromDrizzle(tx, sql) },
);
if (input.amountCents > 1_000_000) {
throw new FraudCheckFailed('amount exceeds $10,000 limit');
}
return { orderId: row.id };
});
} catch (err) {
if (err instanceof FraudCheckFailed) {
console.warn('fraud check failed; transaction rolled back', err.message);
return { rolledBack: true };
}
throw err;
}
}
قم بربط كل شيء في نقطة دخول في src/index.ts:
// src/index.ts
import 'dotenv/config';
import { boss } from './queue.ts';
import { createOrderAtomic } from './orders/create.ts';
import { configureRetries } from './orders/retry.ts';
import { registerFulfillmentWorker, scheduleNightlyReconcile } from './orders/fulfill.ts';
import { createOrderWithFraudCheck } from './orders/rollback-demo.ts';
import { pool } from './db/client.ts';
async function shutdown(): Promise<void> {
console.log('shutting down...');
await boss.stop({ close: true, graceful: true, timeout: 30_000 });
await pool.end();
process.exit(0);
}
process.on('SIGTERM', () => void shutdown());
process.on('SIGINT', () => void shutdown());
async function main(): Promise<void> {
await boss.start();
await configureRetries();
await registerFulfillmentWorker();
await scheduleNightlyReconcile();
const ok = await createOrderAtomic({
customerEmail: 'alice@example.com',
amountCents: 4999,
});
console.log('committed order:', ok);
const rb = await createOrderWithFraudCheck({
customerEmail: 'mallory@example.com',
amountCents: 2_500_000,
});
console.log('fraud demo:', rb);
}
main().catch((err) => {
console.error('fatal', err);
process.exit(1);
});
boss.stop({ close: true, graceful: true, timeout: 30_000 }) يسمح للوظائف النشطة بالانتهاء لمدة تصل إلى 30 ثانية قبل فرض الإغلاق. الخيارات الثلاثة هي أيضًا القيم الافتراضية لـ pg-boss 12.18.2 — close = true, graceful = true, timeout = 30000 — لذا فإن تمريرها صراحةً هنا يوثق النية فقط عند الاستدعاء. عندما يكون close: true، يغلق pg-boss تجمع pg.Pool الداخلي الخاص به (الذي أنشأه من سلسلة الاتصال الخاصة بك) حتى تتمكن العملية من الخروج بشكل نظيف. لاحظ أن stop لا يقبل علامة wait؛ هذا الحقل موجود في OffWorkOptions (المستخدم بواسطة offWork)، وهو مصدر شائع لأخطاء TS2353 عند تعديل الدروس التعليمية من الإصدارات القديمة.
التحقق
قم بتشغيل التطبيق:
npm run dev
يجب أن ترى شيئًا مثل:
committed order: { orderId: '8f3a...', receiptJobId: 'a9c1...' }
fraud check failed; transaction rolled back amount exceeds $10,000 limit
fraud demo: { rolledBack: true }
(لم يتم تسجيل أي عامل لـ order.receipt-email في هذا العرض التوضيحي البسيط، لذا تستقر وظيفة الإيصال في الطابور وتنتظر هناك. هذا هو بالضبط ما نريده لفحص التراجع.) الآن تأكد من التراجع في Postgres مباشرة:
Docker exec -it orders-pg psql -U orders -d orders \
-c "SELECT count(*) FROM orders WHERE customer_email='alice@example.com';"
# count: 1
Docker exec -it orders-pg psql -U orders -d orders \
-c "SELECT count(*) FROM orders WHERE customer_email='mallory@example.com';"
# count: 0
Docker exec -it orders-pg psql -U orders -d orders \
-c "SELECT count(*) FROM pgboss.job WHERE name='order.receipt-email' AND data->>'customerEmail'='mallory@example.com';"
# count: 0
تم حفظ الطلب الناجح، لكن صف "mallory" لم يوجد أبدًا ولم يتم إدراج وظيفتها في الطابور أبدًا. هذا هو ضمان الإدراج الذري (atomic-enqueue). (pgboss.job هو الجدول الأب المقسم الذي تذهب إليه صفوف كل طابور؛ الطوابير غير المقسمة (non-partition) تشترك جميعها في قسم pgboss.job_common افتراضيًا.)
الأخطاء الشائعة وإصلاحها
TS2613: Module 'pg-boss' has no default export.
قم بتغيير import PgBoss from 'pg-boss' إلى import { PgBoss } from 'pg-boss'. إصدار pg-boss 12 هو ESM خالص ويصدر PgBoss كـ named export فقط — ملف dist/index.d.ts المنشور يعلن عنه كـ export declare class PgBoss.
TS2353: Object literal may only specify known properties, and 'retentionDays' does not exist in type 'Omit<Queue, "name">'.
لا يوجد حقل retentionDays في QueueOptions. استخدم retentionSeconds: 60 * 60 * 24 * 14 للحصول على نفس التأثير10. (يوفر pg-boss warningRetentionDays في MaintenanceOptions، ولكن هذا يتحكم في سجل تحذيرات BAM — نطاق مختلف.)
error: relation "pgboss.job" does not exist
لقد استدعيت boss.send أو استعلمت عن الهيكل قبل boss.start(). أول استدعاء لـ start() يقوم بتشغيل عمليات الهجرة (migrations) التي تنشئ الهيكل، والجدول الأب المقسم pgboss.job، والقسم الافتراضي pgboss.job_common. انتظر دائمًا boss.start() قبل أي طريقة أخرى من طرق pg-boss.
أنواع معالج العامل (Worker handler) تظهر كـ any بالنسبة لـ jobs.
قم بتوفير كلا النوعين العامين (generics): boss.work<ReqData, ResData>(name, options, handler). عند استخدام واحد فقط، يسقط بارامتر المعالج إلى توقيع any الافتراضي. بارامتر jobs هو دائمًا مصفوفة Job<ReqData>[]، وليس كائنًا فرديًا Job<ReqData> — راجع ملاحظة "حجم الدفعة" (batch size) في ملف README2.
fromDrizzle غير معرف (undefined) وقت التشغيل.
أنت تستخدم إصدار pg-boss أقل من 12.17.0. المحولات fromDrizzle/fromKnex/fromKysely/fromPrisma جميعها أضيفت في pg-boss 12.17.0 (تم نشره في 2026-04-24)؛ إصدارات 12.x السابقة شحنت فقط واجهة IDatabase اليدوية. قم بالترقية باستخدام npm install pg-boss@>=12.17.0. إذا لم تتمكن من الترقية، فقم بكتابة غلاف IDatabase صغير يستدعي tx.execute(sql.raw(...)) مع إدراج العناصر النائبة بالفعل — sql.raw يقبل سلسلة نصية فقط، لذا سيتعين عليك استبدال قيم $1/$2 بنفسك، وهو بالضبط ما يفعله المحول الرسمي نيابة عنك.
الخطوات التالية
تغطي هذه المجموعة حالة الإدراج الذري (atomic-enqueue) من البداية إلى النهاية، ولكن أنظمة الإنتاج عادةً ما تحتاج لثلاثة أشياء إضافية:
- قابلية الملاحظة (Observability): إصدار نطاقات OpenTelemetry لكل استدعاء لـ
send/workحتى يظهر الطابور بجانب تتبعات HTTP الخاصة بك. يغطي درس Hono middleware للفشل المفتوح مقابل الفشل المغلق نفس النمط لتوزيع المهام في الـ middleware. - تحديد معدل الناشر (Rate-limiting): اربط هذا الطابور بمحدد نافذة منزلقة (sliding-window limiter) حتى لا يتمكن مستخدم واحد من إغراق طابور رسائل البريد الإلكتروني للإيصالات. راجع درس تحديد المعدل بالنافذة المنزلقة باستخدام Upstash للتعرف على المحدد؛ استبدل التخزين في الذاكرة بجدول عدادات Postgres للبقاء داخل قاعدة بيانات واحدة.
- تقسيم جدول المهام (Partitioning): يقوم إصدار pg-boss 12 بالفعل بتقسيم
pgboss.job، مع خيار تفعيل مخصصpartition: trueلكل طابور عالي الكثافة (اسم جدول التقسيم هوj<sha224hash>). للتنظيف التلقائي المعتمد على الوقت لأي جداول مرافقة كبيرة تقوم بتشغيلها بجانبه، يوضح درس أتمتة التقسيم في Postgres 18 باستخدام pg_partman + pg_cron كيفية التقسيم حسبcreated_atبحيث يكون التنظيف عبارة عنDROP PARTITIONبدلاً منDELETE.
Footnotes
-
جدول إصدارات Node.js،
nodejs.org/en/about/previous-releases. دخل Node 24 مرحلة الدعم طويل الأمد النشط (Active LTS) في 2025-10-28 (الاسم الرمزي "Krypton") وهو مدعوم حتى أبريل 2028؛ انتقل Node 22 إلى مرحلة دعم الصيانة (Maintenance LTS) في نفس الفترة تقريبًا. ↩ -
ملف README الخاص بـ pg-boss 12، قسم "ORM Transaction Adapters"،
GitHub.com/timgit/pg-boss. يوثق ملف README تصديرات واستخدامfromKnex، وfromKysely، وfromDrizzle، وfromPrismaداخل معاملات ORM. ملاحظة: وفقًا لـnpm view pg-boss time --json، تم شحن المحولات لأول مرة في pg-boss 12.17.0 (2026-04-24)؛ وهي غير موجودة في الإصدارات من 12.0.0 إلى 12.16.0. ↩ ↩2 -
وثائق npm، "install — save-exact flag"،
docs.npmjs.com/cli/v10/commands/npm-install. الافتراضي هو نطاق علامة الإقحام (caret range)؛ بينما يقوم--save-exactبكتابة الإصدار المحدد. قم بضبطsave-exact=trueفي ملف.npmrcلجعله إعدادًا عالميًا. ↩ -
وثائق Node.js، "Type stripping"،
nodejs.org/API/TypeScript.html#type-stripping. وصل تنفيذ ملفات.tsالأصلي كخيار--experimental-strip-typesفي Node 22.6.0 وتمت ترقيته ليصبح مفعلًا افتراضيًا في Node 22.18.0 (و Node 24). ↩ -
صورة Postgres الرسمية على Docker،
hub.Docker.com/_/postgres. يشير وسمpostgres:18-alpineحاليًا إلى18-alpine3.23(قاعدة Alpine 3.23)؛ يتوفر أيضًا إصدار مثبت18.4-alpine3.23إذا كنت بحاجة إلى تثبيت مستوى تصحيح (patch level) Postgres. ↩ -
وثائق Drizzle ORM، "Type API"،
orm.drizzle.team/docs/goodies#type-API. يتم تصدير$inferSelectو$inferInsertمن كل مثيل لـpgTable. ↩ -
يصرح ملف
package.jsonالخاص بـ pg-boss عن"type": "module"ويقوم ملفdist/index.d.tsالمنشور بتصديرPgBossكفئة مسماة. تم التحقق من ذلك مقابل حزمة npm لـpg-boss@12.18.2. ↩ -
مصدر pg-boss،
dist/adapters/drizzle.ts. تقوم وظيفةexecuteSql(text, values)الخاصة بالمحول بإعادة كتابة العناصر النائبة$1، و$2إلى صيغة القالب الموسوم (tagged-template) الخاصة بـ Drizzle وتستدعيtx.execute(sql(strings, ...reordered)). تنص JSDoc على "بدون تبعية وقت تشغيل علىdrizzle-orm" — حيث يتم توفير استيرادsqlمن قبل المستدعي. ↩ -
وثائق pg-boss API،
timgit.GitHub.io/pg-boss/، و JSDoc الخاص بـQueueOptionsفيnode_modules/pg-boss/dist/types.d.ts. القيمة الافتراضية لـretryLimitهي 2 وretryDelayهي 0؛ صيغة التراجع (backoff) موثقة تحتretryBackoff. ↩ -
تعريفات أنواع pg-boss،
node_modules/pg-boss/dist/types.d.ts. تصدر واجهةQueueOptionsكلاً منexpireInSeconds، وretentionSeconds، وdeleteAfterSeconds، وretryLimit، وretryDelay، وretryBackoff، وretryDelayMax، وdeadLetter. لا يوجدretentionDays. ↩ ↩2
يقوم ملف المصدر timekeeper.js في pg-boss بتفكيك خيارات schedule() كـ { tz = 'UTC', key = '', ...rest } — المنطقة الزمنية الافتراضية هي UTC، وتم التحقق من ذلك في حزمة pg-boss@12.18.2. يتم تحليل تعبيرات Cron بواسطة cron-parser 5.x (معيار cron المكون من 5 حقول، بالإضافة إلى متغير مكون من 6 حقول للثواني). ↩