Skip to main content

همگام‌سازی (آفلاین)

این صفحه قرارداد کامل همگام‌سازی بین کلاینت‌های تقویمم (وب و اندروید) و 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/restorestale، بدون canonical
زندهupsert یا delete با base برابراعمال با رعایت قیود وابستگی
موجودbase تهی یا نابرابرstale، بدون نوشتن
tombstoneupsert با هر baseزنده نمی‌شود؛ stale
tombstonerestore با base برابر نسخهٔ حذفاحیای صریح همراه patch؛ والد باید زنده باشد
tombstonedelete با base برابرapplied بی‌اثر (حذفِ حذف‌شده)
زندهrestorestale

هشدار: صرفاً جایگزینی baseVersion با نسخهٔ جدید و ارسال دوباره، یعنی تصمیم به بازنویسی داده‌ای که دستگاه دیگری (یا سرور) نوشته است. قبل از چنین کاری، نیت محلی و canonical را مقایسه کنید و در رابط کاربر، تصمیم آگاهانه بگیرید. همچنین احیای یک رکورد حذف‌شده همیشه restore صریح می‌خواهد؛ upsert روی tombstone ردیف را زنده نمی‌کند.

reasonها​

وقتی status = error است، فیلد reason علت رد را مشخص می‌کند:

reasonمعنی و اقدام
unknown_parentوالد ارجاع‌شده در همین حساب زنده نیست (یا هنوز ساخته/ارسال نشده). والد را بسازید یا بفرستید، یا ارجاع را اصلاح کنید.
invalid_itemدادهٔ آیتم نامعتبر است: مقدار خارج از بازه، تاریخ خراب، رابطهٔ نامعتبر، خودوالدی یا چرخه در والدِ وظیفه. داده را اصلاح کنید و با mutationId تازه دوباره بفرستید.
has_live_dependentsردیف فرزند زنده دارد و حذف‌پذیر نیست. اول فرزندان را حذف یا به والد دیگری منتقل کنید.
default_calendarتقویم پیش‌فرض زنده، حتی وقتی خالی است، حذف نمی‌شود. برای حذف، اول تقویم دیگری را پیش‌فرض کنید.
parent_deletedrestore والدِ حذف‌شده را نگه می‌دارد. یا اول والد را احیا کنید، یا در همان 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):

  1. uid ردیف محلی را با effectiveUid جایگزین کنید.
  2. همهٔ ارجاع‌هایی که به uid قبلی اشاره می‌کنند (رویدادها، وظیفه‌ها، …) را به‌روز کنید.
  3. نیت‌های باقی‌ماندهٔ outbox که به uid قبلی اشاره دارند را هم اصلاح کنید — و چون محتوایشان تغییر کرده، به هرکدام mutationId تازه بدهید.
  4. همهٔ این‌ها را در یک تراکنش محلی اتمیک انجام دهید تا وسط کار، نیمه‌بازکلید نماند.
  5. اگر کاربر تغییری واقعی روی تقویم می‌خواست، آن ویرایش را نگه دارید و به‌صورت عملیات جدیدی با 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.