اسکریپت seed که تغییرات پنل مدیریت را پاک کرد
اجرای دوباره seed برای افزودن چند برند، متنهای اصلاحشده در پنل را با نسخه قدیمی کد بازنویسی کرد. علت، راهحل و چکلیست جلوگیری از تکرار.
برای افزودن چند برند جدید به یک فروشگاه، اسکریپت seed را دوباره اجرا کردیم. کار سادهای به نظر میرسید و اسکریپت سالها همینطور کار کرده بود. اما برندهای قدیمی هم دوباره نوشته شدند و متنهایی که مدیر در پنل اصلاح کرده بود با نسخهای که در کد مانده بود جایگزین شد. این یادداشت درباره همان اتفاق است و درباره اینکه چرا «اسکریپت idempotent» لزوماً «اسکریپت بیخطر» نیست.
ماجرا و علامتها
هدف فقط اضافه کردن چند برند جدید بود. فایل seed لیست برندها را در کد نگه میداشت و ما همان را دوباره اجرا کردیم تا ردیفهای جدید ساخته شوند. اجرا بدون خطا تمام شد و برندهای جدید هم آمدند.
مشکل در برندهای موجود بود. متنهایی که مدیر بعد از seed اولیه در پنل اصلاح کرده بود، برگشته بودند به همان چیزی که در کد نوشته شده بود. هیچ پیام خطایی وجود نداشت و هیچ لاگی هشدار نداد. اسکریپت از دید خودش کار درستی کرده بود.
همین بیصدا بودن خطرناکترین بخش ماجرا است. اگر اسکریپت شکست خورده بود، همان لحظه میفهمیدیم. اینجا موفق شده بود، و موفقیتش یعنی تغییرات یک آدم دیگر پاک شده بود.
نکات کلیدی
- اجرای seed بدون هیچ خطا و هشداری تمام شد
- برندهای جدید درست ساخته شدند، اما متن برندهای قدیمی به نسخه کد برگشت
- ویرایشهای مدیر در پنل از بین رفت
ردگیری علت
ردگیری طولانی نبود، چون اثر مشاهدهشده دقیقاً با منطق اسکریپت جور درمیآمد. اسکریپت برای هر برند یک upsert انجام میداد: اگر ردیف نبود میساخت، اگر بود فیلدهای آن را با مقدار داخل کد بهروز میکرد.
در ساخت اولیه سیستم این رفتار منطقی بود. seed را یک منبع حقیقت میدیدیم: «کد میگوید برند X این متن را دارد، پس دیتابیس هم باید همان را داشته باشد». upsert هم دوبارهاجراپذیر بود و دو بار اجرا کردنش ردیف تکراری نمیساخت. همین ویژگی باعث شد دقت نکنیم که «دوبارهاجراپذیر» با «محتوای موجود را دست نمیزند» یکی نیست.
چیزی که در این فرضیه جا افتاده بود، گذشت زمان بود. از لحظهای که پنل مدیریت اجازه ویرایش آن متنها را داد، منبع حقیقت عوض شده بود. کد هنوز فکر میکرد مالک داده است و دیتابیس واقعی دیگر چنین نبود.
نکات کلیدی
- رفتار اسکریپت با علامتها کاملاً همخوان بود؛ باگ پنهانی در کار نبود
- فرض قدیمی «کد منبع حقیقت است» بعد از فعال شدن ویرایش در پنل دیگر درست نبود
علت اصلی: مالکیت داده
از نظر فنی، upsert یعنی «درج یا بهروزرسانی». در PostgreSQL این کار با `INSERT ... ON CONFLICT DO UPDATE` انجام میشود و در ORMها معمولاً به شکل متدی با سه بخش `where` و `create` و `update` دیده میشود. مستندات هر دو توضیح میدهند که اگر ردیف وجود داشته باشد، شاخه update اجرا میشود و مقدارهای ارائهشده در آن روی ردیف موجود نوشته میشوند. دیتابیس نمیداند آن مقدار قبلی را یک آدم عمداً عوض کرده یا نه؛ فقط مقدار را جایگزین میکند.
در مقابل، `ON CONFLICT DO NOTHING` ردیف متعارض را ساده رد میکند و چیزی را تغییر نمیدهد. تفاوت این دو در یک جمله، تفاوت دو سیاست مالکیت است. اولی میگوید «کد برنده است» و دومی میگوید «اگر ردیف هست، دست نزن».
نکتهی اصلی این است که مالکیت داده با نوع ستون تعیین نمیشود، با مسیر ویرایش تعیین میشود. ستونی که فقط کد مینویسد (مثلاً کلید یکتا) مالکش کد است. ستونی که از پنل قابل ویرایش است، از لحظهی باز شدن آن مسیر، مالکش آدمهایی هستند که از پنل استفاده میکنند. seed در این مدل فقط حق دارد چیزی را «بسازد»، نه اینکه حقیقت را دوباره تعریف کند.
این با نگاه رایج به تکامل طرح دیتابیس هم سازگار است: schema با مهاجرتهای کوچک و نسخهدار تغییر میکند، اما محتوای ویرایششدهی کاربران چیزی نیست که یک اسکریپت نمونه بتواند بیاجازه بازنویسی کند.
نکات کلیدی
- upsert در شاخهی update هر مقدار ارائهشده را روی ردیف موجود مینویسد
- DO NOTHING یعنی «اگر ردیف هست، دست نزن»
- مالکیت داده را مسیر ویرایش تعیین میکند، نه نوع ستون
راهحل
تصمیمی که گرفتیم دو بخش دارد. اول، seed فقط ردیفهایی را میسازد که وجود ندارند و هیچ ستونی را که ممکن است از پنل ویرایش شده باشد update نمیکند. دوم، قبل از هر اجرای seed روی دیتابیس واقعی، diff ردیفها گرفته میشود تا ببینیم اسکریپت دقیقاً چه چیزی را میسازد یا تغییر میدهد.
نمونهی عمومی بخش اول در SQL (با جدول و ستونهای ساختگی):
اگر واقعاً لازم است چیزی در ردیف موجود بهروز شود، آن را بهصورت یک مهاجرت جداگانه و آگاهانه روی ستون مشخص انجام دهید، نه از مسیر seed.
برای diff قبل از اجرا، سادهترین روش این است که لیست seed را در یک جدول موقت بریزید و با جدول واقعی مقایسه کنید؛ خروجی باید فقط «ردیفهای جدید» را نشان بدهد. اگر ردیفی در هر دو طرف هست و مقدارهایشان فرق دارد، آن اختلاف را نمینویسیم، فقط گزارش میکنیم. این در پروژههای فروشگاه اینترنتی و وباپلیکیشن که محتوای کاتالوگ توسط مدیر نگهداری میشود، همان نقطهای است که مرز بین کد و داده را مشخص میکند.
-- seed: فقط ردیفهای نبود را بساز؛ هیچ ستونی را update نکن
INSERT INTO brands (slug, name, description)
VALUES
('brand-a', 'Brand A', 'Default description A'),
('brand-b', 'Brand B', 'Default description B')
ON CONFLICT (slug) DO NOTHING;
-- diff قبل از اجرا: seed در جدول موقت، مقایسه با جدول واقعی
CREATE TEMP TABLE seed_brands (slug text, name text, description text);
-- ... درج دادههای seed در seed_brands ...
-- ردیفهای جدید (فقط همینها ساخته میشوند)
SELECT s.slug FROM seed_brands s
LEFT JOIN brands b USING (slug)
WHERE b.slug IS NULL;
-- ردیفهای موجود که با کد فرق دارند (فقط گزارش، بدون نوشتن)
SELECT s.slug, b.description AS live, s.description AS seed
FROM seed_brands s JOIN brands b USING (slug)
WHERE b.description IS DISTINCT FROM s.description;نکات کلیدی
- seed فقط میسازد و هرگز ردیف موجود را update نمیکند
- قبل از اجرا روی دیتابیس واقعی، diff ردیفها گرفته میشود
- هر تغییر در ردیف موجود باید مهاجرت جداگانه و آگاهانه باشد
درسها و چکلیست
درس اصلی همان جملهای است که از این ماجرا ماند: دادهای که از پنل قابل ویرایش است، دیگر مالکیتش با کد نیست. کد میتواند مقدار اولیه بدهد، اما بعد از آن حق بازنویسی ندارد.
در پروژههایی مثل فروشگاه عینکسازی که کاتالوگ و متنها توسط مدیر نگهداری میشود، این مرز باید از ابتدا روشن باشد. و همانطور که در چکلیست انتقال بدون قطعی هم اصرار داریم، هر کاری روی دادهی واقعی باید اول در حالت «ببین چه میشود» اجرا شود و بعد در حالت واقعی.
چکلیستی که حالا قبل از seed روی دیتابیس واقعی میرود:
۱) هر ستون را بررسی کن: آیا از پنل یا هر مسیر دیگری قابل ویرایش است؟ اگر بله، seed حق نوشتن روی آن را ندارد.
۲) بهجای upsert از «درج در صورت نبود» استفاده کن و شاخهی update را حذف کن.
۳) قبل از اجرا diff بگیر و خروجی را بخوان؛ باید فقط ردیفهای جدید دیده شوند.
۴) قبل از هر اجرای واقعی نسخهی پشتیبان بگیر، تا اگر چیزی غیرمنتظره بود راه برگشت باشد.
۵) اگر مقدار پیشفرض در کد تغییر کرده و باید روی ردیفهای موجود هم اعمال شود، آن را مهاجرت مستقل بنویس و صریحاً بررسی کن که کدام ردیفها دستکاری نشدهاند.
جمعبندی
upsert فقط یک ابزار است و سیاست مالکیت را خودمان باید تعیین کنیم. وقتی ستونی به پنل مدیریت باز میشود، کد از مالک آن به یک مهمان تبدیل میشود که فقط اجازه دارد چیزِ نبود را بسازد. seed را همینطور بنویسید و قبل از اجرا روی دادهی واقعی، diff را ببینید.
منابع
- PostgreSQL Documentation: INSERT (ON CONFLICT)postgresql.org
- Prisma Docs: Writing data (upsert)prisma.io
- Evolutionary Database Design (Martin Fowler, Pramod Sadalage)martinfowler.com