همگامسازی (آفلاین)
این صفحه قرارداد کامل همگامسازی بین کلاینتهای تقویمم (وب و اندروید) و API است. سیستم آفلایناول است: کاربر هر تغییری را ابتدا در پایگاه دادهٔ محلی خود ذخیره میکند و کلاینت بعداً آن را به سرور میفرستد. بنابراین «ذخیره شد» در رابط کاربری هیچوقت به معنی «سرور پذیرفت» نیست.
سه مفهوم پایه وجود دارد که بقیهٔ صفحه بر آنها بنا میشود:
- نیت (intent): هر تغییر محلی که هنوز از سرور پاسخ نگرفته، یک نیت است. نیتها در صفی به نام
outboxنگه داشته میشوند تا ارسال شوند. - canonical: آخرین وضعیت تأییدشدهٔ سرور از یک ردیف. هر چه سرور در پاسخ push برمیگرداند یا pull میفرستد canonical است؛ پیشنویس محلی کاربر میتواند با آن فرق داشته باشد و تا وقتی push نشده، فقط محلی است.
- رسید (receipt): سرور نتیجهٔ هر عملیات را بهصورت ماندگار نگه میدارد تا اگر پاسخ گم شد، تکرار همان عملیات نتیجهٔ اصلی را بدون نوشتن دوباره برگرداند. جزئیات در بخش رسید.
نکته مهم: یک نیت را فقط زمانی از
outboxحذف کنید که نتیجهٔ همانmutationIdرا در پاسخ push دریافت کردهاید. پاسخpullهیچوقت تأییدکنندهٔ عملیاتهای push نیست، وHTTP 200هم به معنی موفقیت همهٔ آیتمهای داخل batch نیست — هر آیتم نتیجهٔ مستقل خودش را دارد.
همهٔ مسیرهای این صفحه به Bearer token حساب نیاز دارند (به احراز هویت مراجعه کنید).
| مسیر | کار |
|---|---|
POST /api/sync/push | ارسال تغییرات محلی؛ سرصفحهٔ X-Sync-Protocol: 2 اجباری است |
GET /api/sync/changes?limit=500 | دریافت تغییرات سرور؛ صفحهٔ بعدی با cursor موجود در پاسخ |
GET /api/sync/conflicts | فهرست تعارضهای تاریخی نسل قبل — نه تعارضهای پروتکل ۲ |
POST /api/sync/conflicts/:id/resolve | تصمیمگیری دربارهٔ یک تعارض تاریخی |
شناسهها و مفاهیم پایه
uid: هویت پایدار یک ردیف که کلاینت هنگام ساخت میسازد (UUIDv7) و در همهٔ دستگاهها یکی میماند. ارجاعهای بین ردیفها (مثلcalendarUidیک رویداد) هم با uid انجام میشود، نه با شناسهٔ عددی داخلی دیتابیس.mutationId: شناسهٔ یکتای هر عملیات در محدودهٔ یک حساب. هر تغییر محلی جدید باید mutationId تازه بگیرد؛ تکرار همین شناسه بعداً همان نقش «تلاش مجدد همان عملیات» را دارد.version: شمارهٔ نسخهٔ ردیف در سرور، بهصورت رشتهٔ دهدهی (مثلاً"23"). هر نوشتن سرور نسخه را از یک دنبالهٔ سراسری بالا میبرد. هرگز آن را عدد JSON یا timestamp فرض نکنید.baseVersion: نسخهای از ردیف که کلاینت تغییر خود را روی آن بنا کرده است. سرور با مقایسهٔbaseVersionباversionفعلی تشخیص میدهد که آیا تغییر قابل اعمال است یا دیر رسیده (stale).deleted: زمان حذف نرم بهصورت ISO، یاnullبرای ردیف زنده. ردیفِ حذفشدهٔ نرم «tombstone» (نشان حذف) نامیده میشود و همچنان با pull برگردانده میشود.- cursor: رشتهٔ مبهم و امضاشده که پیشرفت pull را برای هر نوع موجودیت نگه میدارد. محتوایش را parse یا ساختنش تلاش نکنید؛ فقط همان چیزی که سرور داده را ذخیره و پس بفرستید.
ساعت دستگاه هیچوقت معیار تازگی داده نیست؛ فقط version (برای ردیف) و cursor (برای پیشرفت pull) مبنای سروریاند.
Push
درخواست push با Content-Type: application/json، Bearer token و سرصفحهٔ دقیق X-Sync-Protocol: 2 ارسال میشود. بدون این سرصفحه (یا با مقدار دیگری) سرور پیش از هر اعتبارسنجی و هر نوشتن، با 426 جواب میدهد.
نمونهٔ زیر یک patch روی وظیفهٔ موجود با نسخهٔ ۲۳ است — فقط title عوض میشود و بقیهٔ فیلدها دستنخورده میمانند:
{
"tasks": [
{
"mutationId": "019a3ec0-0000-7000-8000-000000000001",
"uid": "019a3ec0-0000-7000-8000-000000000002",
"op": "upsert",
"baseVersion": "23",
"title": "خرید نان"
}
]
}
معنای فیلدهای patch این است:
- فیلدی که در JSON نیامده، مقدار فعلی سرور را حفظ میکند.
nullفقط برای فیلدهای nullable معنا دارد و مقدار آن فیلد را پاک میکند. پس غیبتِ فیلد باnullفرق دارد.createdAtاختیاری است و فقط هنگام ساخت ردیف جدید اثر دارد؛ در ویرایش نادیده گرفته میشود.updatedAtوdeletedفیلدهای ورودی نیستند؛ سرور خودش آنها را مدیریت میکند.- سه عمل ممکن است:
upsert(ساخت یا ویرایش)،delete(حذف نرم) وrestore(احیای صریح یک tombstone).
ترتیب پردازش و استقلال آیتمها
سرور همیشه آرایهها را به ترتیب ثابت calendars → events → tasks → reminders پردازش میکند و داخل هر آرایه، ترتیب ورودی کلاینت را حفظ میکند. هر آیتم در تراکنش مستقل خودش اعمال میشود؛ نتیجهٔ یک آیتم، سرنوشت آیتم بعدی را تعیین نمیکند.
نتیجهٔ عملی برای کلاینت:
- ساخت: والد باید قبل از فرزند ارسال شود — از جمله والدِ یک زیروظیفه. چون تقویمها قبل از رویدادها و رویدادها قبل از وظیفهها پردازش میشوند، میتوانید والد و فرزند را در همان یک batch بفرستید.
- حذف: برعکس — فرزند باید قبل از والد حذف شود. چون ترتیب آرایهها در سرور ثابت است، معمولاً درستترین کار این است که فرزندان را در push قبلی حذف و تأییدشان را بگیرید، بعد حذف والد را بفرستید.
- شکست والد (مثلاً بهدلیل
unknown_parent) میتواند آیتمهای وابستهٔ بعدی را هم به خطا بکشاند.
اعتبارسنجی مقادیر
تاریخها باید قابل parse باشند. order وظیفه عدد صحیح signed Int32 است، value یادآور عدد صحیح مثبت Int32، و providerId عدد صحیح در بازهٔ امن JSON (±9007199254740991). مقدار نامعتبر قبل از ذخیره به نتیجهٔ error همان آیتم تبدیل میشود — نه خطای 500 سرور و نه رد خودکار کل batch. در مقابل، خرابی خود پاکت درخواست (envelope) همچنان کل درخواست را با 400 رد میکند؛ تفکیک این دو در بخش خطاها آمده است.
پاسخ push
پاسخ push برای هر آیتم ارسالی دقیقاً یک نتیجه دارد. نتیجهها را با mutationId تطبیق دهید، نه فقط با uid — چون در برخی حالتها (مثل ادغام تقویم پیشفرض) uid مؤثر با uid درخواستی فرق میکند.
نمونهٔ کامل یک نتیجهٔ stale، برای وقتی که وظیفهٔ نمونهٔ بالا دیگر در سرور وجود ندارد:
{
"success": true,
"data": {
"calendars": [],
"events": [],
"tasks": [
{
"mutationId": "019a3ec0-0000-7000-8000-000000000001",
"requestedUid": "019a3ec0-0000-7000-8000-000000000002",
"effectiveUid": "019a3ec0-0000-7000-8000-000000000002",
"status": "stale",
"canonical": null
}
],
"reminders": [],
"conflicts": []
}
}
فیلد canonical در نتیجه:
- در
applied: ردیفی که واقعاً نوشته شده. - در
stale: وضعیت فعلی سرور که با نیت شما واگرا شده («واگرا» یعنی سرور از نسخهای که شما مبنا گذاشتهاید جلوتر رفته). - در
error: ممکن است مقدار داشته باشد یاnullباشد؛ به تهیبودن آن تکیه نکنید.
| وضعیت | معنی | کاری که کلاینت باید انجام دهد |
|---|---|---|
applied | سرور عملیات را پذیرفت و نوشت | نتیجه را با mutationId به نیت متناظر در outbox وصل کنید، canonical را در دیتابیس محلی ذخیره کنید و فقط همان یک نیت را از outbox حذف کنید. اگر بعد از آن نیت، ویرایش جدیدتری روی همان ردیف در outbox هست، آنها را حذف نکنید — مال همانها هنوز پاسخ نگرفتهاند. |
stale | پیششرط نسخه برقرار نبود و چیزی نوشته نشد | نیت را نگه دارید (حذفش نکنید)، آن را با canonical برگشتی مقایسه کنید و بر اساس تصمیم کاربر یا سیاست برنامه، عملیات جدیدی با baseVersion و mutationId تازه بسازید. |
error | سرور آیتم را رد کرد؛ فیلد reason علت را میگوید | علت را برطرف کنید و برای تلاش جدید mutationId تازه بسازید — تکرار همان شناسه، نتیجهٔ قدیمی را تکرار میکند (بخش رسید). |
پاسخهای applied/stale/error همگی داخل HTTP 200 برمیگردند؛ موفقیت سطح HTTP با موفقیت آیتم یکی نیست.
نسخه و tombstone
جدول زیر نتیجهٔ ترکیب وضعیت فعلی ردیف در سرور با درخواست کلاینت را نشان میدهد. «base برابر» یعنی baseVersion دقیقاً version فعلی سرور باشد:
| وضعیت سرور | درخواست | نتیجه |
|---|---|---|
| غایب | upsert با baseVersion=null | ساخت ردیف جدید (applied) |
| غایب | upsert با base غیرتهی، یا delete/restore | stale، بدون canonical |
| زنده | upsert یا delete با base برابر | اعمال با رعایت قیود وابستگی |
| موجود | base تهی یا نابرابر | stale، بدون نوشتن |
| tombstone | upsert با هر base | زنده نمیشود؛ stale |
| tombstone | restore با base برابر نسخهٔ حذف | احیای صریح همراه patch؛ والد باید زنده باشد |
| tombstone | delete با base برابر | applied بیاثر (حذفِ حذفشده) |
| زنده | restore | stale |
هشدار: صرفاً جایگزینی
baseVersionبا نسخهٔ جدید و ارسال دوباره، یعنی تصمیم به بازنویسی دادهای که دستگاه دیگری (یا سرور) نوشته است. قبل از چنین کاری، نیت محلی وcanonicalرا مقایسه کنید و در رابط کاربر، تصمیم آگاهانه بگیرید. همچنین احیای یک رکورد حذفشده همیشهrestoreصریح میخواهد؛upsertروی tombstone ردیف را زنده نمیکند.
reasonها
وقتی status = error است، فیلد reason علت رد را مشخص میکند:
| reason | معنی و اقدام |
|---|---|
unknown_parent | والد ارجاعشده در همین حساب زنده نیست (یا هنوز ساخته/ارسال نشده). والد را بسازید یا بفرستید، یا ارجاع را اصلاح کنید. |
invalid_item | دادهٔ آیتم نامعتبر است: مقدار خارج از بازه، تاریخ خراب، رابطهٔ نامعتبر، خودوالدی یا چرخه در والدِ وظیفه. داده را اصلاح کنید و با mutationId تازه دوباره بفرستید. |
has_live_dependents | ردیف فرزند زنده دارد و حذفپذیر نیست. اول فرزندان را حذف یا به والد دیگری منتقل کنید. |
default_calendar | تقویم پیشفرض زنده، حتی وقتی خالی است، حذف نمیشود. برای حذف، اول تقویم دیگری را پیشفرض کنید. |
parent_deleted | restore والدِ حذفشده را نگه میدارد. یا اول والد را احیا کنید، یا در همان restore ارجاع را به والد زندهٔ دیگری تغییر دهید. |
identity_mismatch | این mutationId قبلاً با نوع موجودیت یا محتوای دیگری استفاده شده است. برای این تغییر شناسهٔ تازه بسازید. |
default_identity | (از نوع stale، نه error) تقویم پیشفرض محلی شما به هویت سروری وصل شد؛ توضیح کامل در بخش بعد. |
قواعد وابستگی بین موجودیتها
حذف این ردیفها مسدود است و حذف آبشاری خودکار وجود ندارد:
- تقویم، وقتی رویداد زنده، وظیفهٔ زنده یا یادآور زندهای متصل به رویدادهایش دارد؛
- رویداد، وقتی یادآور زنده دارد؛
- وظیفه، وقتی زیروظیفه یا یادآور زنده دارد.
یادآور دقیقاً یک والد دارد: یک Event یا یک Task — نه هر دو، نه هیچکدام. پیوندهای وظیفه (تقویم، رویداد، والد) همه اختیاریاند.
تقویم پیشفرض و identity سروری
هر حساب دقیقاً یک تقویم پیشفرض زنده دارد و سرور این هویت را ثابت نگه میدارد. اگر کلاینتِ آفلاین یک تقویم پیشفرض محلی با uid خودساخته بسازد و آن را با upsert، baseVersion=null و isDefault:true بفرستد، در حالی که حساب از قبل تقویم پیشفرض زنده دارد، سرور ردیف دومی نمیسازد. نتیجه stale ب ا reason: default_identity است:
- هیچیک از فیلدهای محلی شما نوشته نمیشود؛
effectiveUidبرابر uid تقویم پیشفرض سروری است؛canonicalهمان ردیف تقویم سروری است — مبنای سروری، نه تأییدی برای فیلدهای محلی شما.
کاری که کلاینت باید در این حالت انجام دهد («بازکلید» یا re-key):
- uid ردیف محلی را با
effectiveUidجایگزین کنید. - همهٔ ارجاعهایی که به uid قبلی اشاره میکنند (رویدادها، وظیفهها، …) را بهروز کنید.
- نیتهای باقیماندهٔ outbox که به uid قبلی اشاره دارند را هم اصلاح کنید — و چون محتوایشان تغییر کرده، به هرکدام
mutationIdتازه بدهید. - همهٔ اینها را در یک تراکنش محلی اتمیک انجام دهید تا وسط کار، نیمهبازکلید نماند.
- اگر کاربر تغییری واقعی روی تقویم میخواست، آن ویرایش را نگه دارید و بهصورت عملیات جدیدی با
baseVersionتازه رویeffectiveUidبفرستید.
هشدار: تکرار همان درخواست با uid محلی قبلی راهحل نیست — دفعهٔ بعد هم همین
stale/default_identityرا میگیرید.
یک قاعدهٔ مرتبط: اگر با upsert تقویم موجود را پیشفرض کنید، سرور بقیهٔ تقویمهای پیشفرض زنده را از پیشفرض بودن برمی دارد (demote). این تغییرات جانبی روی ردیفهای دیگر، از طریق pull به دستتان میرسد؛ در پاسخ همان push نیست.
رسید و بازتلاش
سرور نتیجهٔ هر (userId, mutationId) را همراه خودِ اعمال، اتمیک ذخیره میکند. نکتهٔ مهم: stale و error هم رسید دارند، فقط نتیجههای applied.
این چه چیزی به کلاینت میدهد؟ اگر پاسخ push گم شود (قطع شبکه، timeout، crash بعد از ارسال)، میتوانید همان آیتم را با همان mutationId و همان محتوا دوباره بفرستید؛ سرور نتیجهٔ اصلی را بدون نوشتن دوباره برمیگرداند. برای همین است که بعد از خطای شبکه یا 5xx، ارسال دوبارهٔ همان عملیات امن است — ممکن است بعضی آیتمها واقعاً commit شده باشند.
قواعد انطباق رسید:
- ترتیب کلیدهای JSON مهم نیست؛ سرور قبل از انگشتگذاری، کلیدها را مرتب میکند.
- غیبت یک فیلد با
nullفرق دارد — رسید متفاوت محسوب میشود. - تغییر uid، baseVersion، نوع موجودیت یا خود patch، همان «عملیات جدید» است و
mutationIdتازه میخواهد؛ با شناسهٔ قبلی، خطایidentity_mismatchمیگیرید.
نکته مهم: اگر یک آیتم بهدلیل
unknown_parentخطا خورد و شما والد را ساخته و دوباره همان آیتم را با همان mutationId فرستادید، سرور نتیجهٔ قدیمی (همان error) را تکرار میکند، چون رسیدش موجود است. رفع علت و تلاش مجدد همیشه شناسهٔ تازه میخواهد.
دو نکتهٔ دیگر دربارهٔ canonical در رسیدها:
- canonical یک رسید ممکن است نسبت به الان قدیمی باشد (مثلاً دستگاه دیگری از آن موقع بیشتر نوشته). نسخهٔ تأییدشدهٔ جدیدتری که دارید را با آن عقب نبرید — canonical را فقط وقتی ذخیره کنید که از نسخهٔ فعلی محلیتان جدیدتر یا همان باشد.
- پاسخ push هیچ اثری روی cursor ندارد؛ cursor فقط از pull جلو میرود.
Pull و مرز بالا
pull تغییرات سرور را از جایی که قبلاً رسیدهاید به بعد برمیگرداند. سرور زیر یک قفل مخصوص کاربر کار میکند: در یک لحظه، یک «مرز بالای دنباله» (upper) خوانده میشود و ردیفهایی برمیگردند که watermark < version <= upper باشند — watermark یعنی آخرین نسخهای که برای آن نوع موجودیت قبلاً گرفتهاید. نوشتنهای همزمان سرور هم قبل از گرفتن نسخه، همان قفل را میگیرند؛ پس صفحههای pull با نو شتنهای جدید تداخل ناسازگار ندارند.
دو نکتهٔ مهم دربارهٔ ماهیت این داده:
- pull تاریخچهٔ همهٔ ویرایشها نیست؛ از هر ردیف فقط آخرین وضعیت (در بازهٔ خواندهشده) برمیگردد.
- tombstoneها هم برمیگردند — حذف یک ردیف در سرور، با رسیدن ردیف tombstoneشده به شما میرسد.
مشخصات صفحهبندی:
limitپیشفرض ۵۰۰ و حداکثر ۱۰۰۰ است و برای هر نوع موجودیت مستقل اعمال میشود، نه برای کل پاسخ.- پاسخ چهار آرایهٔ
calendars/events/tasks/reminders، بهعلاوهٔnextCursor،hasMoreوcursorResetدارد. - تا وقتی
hasMore=trueاست باnextCursorادامه دهید. صفحهها snapshot ثابت یک لحظه نیستند؛ صفحات بعدی ممکن است تغییرات جدیدتری هم داشته باشند.
نکته مهم: ردیفهای دریافتی و cursor جدید را در یک تراکنش محلی ذخیره کنید. اگر ذخیره شکست بخورد و شما cursor را جلو برده باشید، آن صفحه از دست میرود و تا تغییر بعدی آن ردیفها برنمیگردند.
قواعد cursor:
- cursor غایب، خراب، با امضای نامعتبر، متعلق به حساب دیگر یا با قالب ناسازگار: pull از صفر شروع میشود و
cursorReset=trueبرمیگردد. این رفتار خطا نیست. - reset هم مثل حالت عادی صفحهبندی دارد؛ یعنی
cursorReset=trueهم به معنی «همهٔ داده در همین پاسخ هست» نیست، هم به معنی پاکشدن outbox نیست. - یک cursor قدیمی که هنوز فیلد tasks ندارد (از نسخهٔ قبلی قالب) پذیرفته میشود و پیشرفت tasks از صفر شروع میشود.
حفاظت از دادهٔ محلی هنگام pull:
- canonical را از پیشنویسهای محلی در انتظار ارسال (pending) جدا نگه دارید. pull نباید پیشنویس کاربر را overwrite کند و نباید نیتهای outbox را مصرف یا حذف کند.
- قبل از اعمال پاسخ، مطمئن شوید پاسخ متعلق به همان حساب و همان نشست فعلی است؛ پاسخ دیررسِ حساب یا نشست قبلی نباید وارد حساب جدید شود.
خطاهای درخواست
خطاهای زیر در سطح کل درخواستاند (نه آیتم):
| خطا | معنی و اقدام |
|---|---|
426 sync_protocol_upgrade_required | سرصفحهٔ دقیق X-Sync-Protocol: 2 نیست. این gate قبل از هر اعتبارسنجی و نوشتن است؛ کلاینت را ارتقا دهید و outbox را نگه دارید. |
400 | پاکت خالی/نامعتبر، یا mutationId غایب/غیر UUID/تکراری داخل batch. هیچ آیتمی اعمال نمیشود. |
413 | بیش از ۱۰۰۰ آیتم در یک آرایه، یا بدنهٔ JSON بزرگت ر از ۱MB. batch را کوچکتر کنید. |
401 | توکن منقضی است؛ refresh یا ورود دوباره — نه حذف دادهٔ آفلاین. |
429 | سقف مشترک مسیرهای sync: ۶۰۰ درخواست در ۱۵ دقیقه برای هر کاربر. با تأخیر دوباره تلاش کنید. |
شبکه / 5xx | ممکن است آیتمهای قبلی واقعاً commit شده باشند؛ همان عملیاتهای اصلی را دوباره بفرستید تا رسیدها جواب بدهند. |
تفکیک مهم دو نوع شکست آیتم:
- خطای دامنهای آیتم (یکی از reasonهای جدول بالا): فقط همان آیتم
errorمیشود و بقیهٔ batch مستقل ادامه مییابد. - خطای غیرمنتظرهٔ اجرا/دیتابیس: ممکن است پردازش بقیهٔ batch را متوقف کند و کل درخواست به خطا برود — بدون rollback آیتمهای قبلی. آیتمهای commitشده با رسیدشان امناند؛ تکرار همان عملیات، نتیجهٔ ثبتشده را برمیگرداند.
تعارض تاریخی و محلی
در پروتکل ۲، تعارض یعنی واگرایی نیت محلی با canonical سرور که به stale میرسد و تصمیمش با کلاینت است. این تعارضها در پاسخ push برگردانده میشو ند؛ push جدید هرگز ردیف SyncConflict نمیسازد و آرایهٔ conflicts آن همیشه خالی است.
مسیرهای /api/sync/conflicts فقط سوابق نسل قبل (پروتکل ۱) را مدیریت میکنند:
dismiss: تعارض حلشده علامت میخورد، بدون تغییری در موجودیت.restore: فیلدهای قابل ویرایشِ موجود در snapshot بازنده، روی ردیف فعلی بازنویسی میشود و tombstone پاک میشود — ممکن است دادهٔ فعلی را جایگزین کند و حتی حذف را احیا کند.
این restore تاریخی با restore آیتم پروتکل ۲ فرق دارد: بدون پیششرط baseVersion اجرا میشود و مسیر تصمیمگیری متفاوتی دارد. ممیزی قدیمی سند تاریخچه است، نه قرارداد امروز.
کلاینتها
پیادهسازی مرجع ذخیرهسازی، خروج امن و حل تعارض: وب و اندروید. هر کلاینت جدیدی که این پروتکل را پیاده میکند باید حداقل اینها را رعایت کند: نگهداری pending جدا از canonical، جداسازی حسابها، ذخیرهٔ اتمیک صفحه و cursor، و بازتلاش فقط با همان mutationId و محتوای اصلی.
چرا فقط sync؟
برای چهار موجودیت اصلی، CRUD جداگانهای ثبت نشده است و همهٔ خواندن/نوشتن از مسیرهای همین صفحه میگذرد. مرجع دقیق رفتار: تعاریف V2 در packages/contracts/src/sync.ts، apps/api/src/services/syncService.ts و apps/api/src/routes/sync.ts؛ شکل کامل فیلدها و مثالها در مرجع API.