Skip to main content

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

همگام‌سازی یک مدل آفلاین، چند‌دستگاهه و دو‌طرفه است که بدون از دست رفتن داده کار می‌کند. چهار موجودیت از آن عبور می‌کنند: Calendar، Event، Task و Reminder — همگی از طریق یک مسیرِ واحدِ /api/sync.

وضعیت پیاده‌سازی

جزءوضعیتتوضیح
سرور (apps/api)✅ پیاده‌سازی‌شدههر چهار endpoint در src/routes/sync.ts فعال است و دقیقاً رفتارِ همین صفحه را پیاده می‌کند.
کلاینت وب (apps/calendar-web)✅ پیاده‌سازی‌شدهموتور آفلاین کامل: Dexie، outbox، انتخاب رهبر و رابط تعارض. جزئیات در بخش پایین همین صفحه و کلاینت وب.
کلاینت اندروید (apps/mobile)❌ پیاده‌سازی‌نشدههیچ بخشی از این پروتکل را پیاده نمی‌کند: پایگاه‌دادهٔ Room فقط تقویم دارد (بدون version، بدون outbox و cursor). وضعیت و نقشهٔ راه در کلاینت اندروید.

endpoint‌ها

متدمسیرتوضیح
POST/api/sync/pushارسال دسته‌ای تغییرات محلی
GET/api/sync/changes?cursor=&limit=دریافت تغییرات سرور از cursor
GET/api/sync/conflictsفهرست تعارض‌های حل‌نشده
POST/api/sync/conflicts/:id/resolveبازگردانی یا رد یک تعارض

شناسه و نسخه

هر رکورد دو مهر دارد که مالکیت‌شان جدا است:

  • uid — هویتِ رکورد، ساختهٔ کلاینت (مثلاً UUIDv7). یک رکورد در همهٔ دستگاه‌ها و برای همیشه همان uid را دارد؛ سرور فقط آن را ذخیره می‌کند.
  • version — شمارهٔ تغییر، ساختهٔ فقط سرور. کلاینت هرگز آن را نمی‌سازد یا حدس نمی‌زند؛ فقط آخرین مقداری را که دیده نگه می‌دارد.

پشتِ صحنه، یک trigger در سطح دیتابیس (bump_record_version) هنگام هر INSERT/UPDATE روی جداولِ همگام‌شونده اجرا می‌شود: از دنبالهٔ سراسری record_version_seq یک عدد bigint می‌گیرد، در ستون version می‌نویسد و یک advisory lock به‌ازای همان کاربر می‌گیرد (نقشِ این قفل در Pull می‌آید). حذفِ نرم (soft delete) هم فقط یک UPDATE است، پس tombstone هم version جدید می‌گیرد. چون اعداد می‌توانند از ۲^۵۳ بزرگ‌تر شوند، روی wire همیشه رشتهٔ اعشاری‌اند: "31"، نه 31.

یک نمونهٔ مشخص:

  1. کلاینت رکوردی با version: "23" دارد و کاربر عنوانش را ویرایش می‌کند.
  2. کلاینت در push فقط فیلدهای تغییرکرده به‌همراه baseVersion: "23" می‌فرستد.
  3. سرور می‌بیند ردیف هنوز روی 23 است (clean)، فیلدها را اعمال می‌کند و trigger نسخهٔ "31" می‌دهد.
  4. پاسخ version: "31" است؛ کلاینت 31 را جایگزین 23 ذخیره می‌کند.

baseVersion

baseVersion یعنی «آخرین versionی که کلاینت هنگام ساختنِ تغییر از این ردیف دیده». سرور آن را با version فعلی ردیف مقایسه می‌کند: برابر → نوشته clean است؛ نابرابر → stale. null یعنی کلاینت این ردیف را هرگز ندیده — سیگنالِ create.

  • کلاینت baseVersion می‌فرستد، نه version جدید را؛ version جدید را فقط سرور می‌سازد.
  • آیتمِ upsert فقط فیلدهای تغییرکرده را حمل می‌کند (patch). فیلدِ غایب یعنی «بدون تغییر»، نه «پاک کن».

cursor

Pull با یک cursor امضاشده هدایت می‌شود. payload داخلش این است:

{ "v": 1, "userId": 42, "calendars": "30", "events": "0", "tasks": "0", "reminders": "0" }

هر کلیدِ موجودیت یک واترمارک است: «تا version چند از این نوع خوانده‌ام». رشتهٔ cursor برابر است با base64url(payload) + "." + HMAC-SHA256(payload, SYNC_CURSOR_SECRET) — امضا با کلیدِ سرور، پس کلاینت نمی‌تواند cursor را بسازد یا دستکاری کند.

  • اعتبارسنجی: cursor غایب یا بدشکل، امضای نامعتبر، userId متفاوت، یا v != 1 → سرور همهٔ واترمارک‌ها را صفر می‌کند، cursorReset: true برمی‌گرداند و به‌جایش یک snapshot کامل می‌فرستد. (تنها استثنا: cursorهای قدیمیِ بدون tasks به‌جای reset، tasks: "0" فرض می‌شوند.)
  • صفحه‌بندی: limit بین ۱ تا ۱۰۰۰ محدود می‌شود (پیش‌فرض ۵۰۰). اگر تعدادی از انواع قطع شوند، hasMore: true است و واترمارکِ همان نوع فقط تا آخرین ردیفِ برگردانده‌شده جلو می‌رود؛ انواعِ کامل تا انتها می‌روند. پاسخ، nextCursor امضاشدهٔ جدید را دارد — آن را فقط بعد از یک pull کاملِ موفق ذخیره کن.
  • واترمارک می‌تواند از version رکوردها بزرگ‌تر باشد. مثلاً calendars: "30" وقتی هیچ تقویمی version بزرگ‌تر از ۲۳ ندارد — طبیعی است، چون دنباله روی هر چهار جدول و همهٔ کاربرها مشترک است و واترمارکِ نوعِ بدونِ تغییر تا کرانِ بالا می‌پرد. واترمارک «جای‌نمای مصرف» است، نه «بیشینهٔ نسخه‌های آن جدول».
  • cursor برای کلاینت یک رشتهٔ مبهم است. آن را parse نکن و خودت نساز؛ فقط همان‌طور که سرور داده نگه دار و پس بده.

سه مفهومِ شبیه‌به‌هم را جدا نگه دارید:

مفهومچیست
version رکوردوضعیتِ یک ردیف خاص: «این ردیف الان کجای دنباله است».
baseVersionمدرکِ تغییر: «من این mutation را روی نسخهٔ چند ساختم».
واترمارکِ cursor (مثل events)پیشرفتِ مصرف: «تغییراتِ events را تا نسخهٔ چند خوانده‌ام».

Pull و مرزِ بالا

Pull در یک transaction و با همان advisory lock به‌ازای کاربر انجام می‌شود و برای هر نوع چنین می‌خواند:

SELECTWHERE "version" > :watermark AND "version" <= :upper
ORDER BY "version" LIMIT :n

upper همان last_value دنباله است که زیر همان قفل خوانده می‌شود. چون trigger هم برای گرفتنِ نسخهٔ جدید باید همان قفل را بگیرد، وسطِ یک pull هیچ نسخه‌ای مقدار <= upper نمی‌گیرد؛ نتیجه این «پیشوندِ دقیق» است: دلتای برگردانده‌شده هیچ تغییری را برای همیشه از قلم نمی‌اندازد. کاربران دیگر تحت تأثیر قرار نمی‌گیرند — قفل فقط روی رکوردهای همان کاربر است. تومب‌استون‌ها (رکوردهای حذفِ نرم) هم در همین دلتا برمی‌گردند تا کلاینت رکورد را محلی هم حذف کند.

هر موجودیتِ برگشتی در pull دو مهرِ زمانی سروری هم دارد: createdAt و updatedAt (رشتهٔ ISO-8601). این‌ها فقط خواندنی‌اند؛ در push فرستاده نمی‌شوند و اعمال نمی‌شوند — سنجاقِ نسخه همچنان version است.

Push

شکلِ درخواست:

POST /api/sync/push
{
"calendars": [{ "uid": "…", "op": "upsert", "baseVersion": "23", "name": "کار" }],
"events": [{ "uid": "…", "op": "delete", "baseVersion": "10" }],
"tasks": [],
"reminders": []
}
  • هر آرایه حداکثر ۱۰۰۰ آیتم دارد (بیشتر → 413).
  • هر آیتم: uid، op (upsert یا deletebaseVersion و فقط فیلدهای تغییرکرده.
  • ترتیبِ اعمال: calendars → events → tasks → reminders، و هر آیتم در transaction خودش اعمال می‌شود (قفلِ کاربر + FOR UPDATE روی ردیف). نتیجه: والد و فرزند می‌توانند در یک درخواست بیایند — والدی که زودتر اعمال شده، مرجعِ FK فرزند (calendarUid، parentTaskUid، …) را در همان batch حل می‌کند.

ماتریس تصمیم

سرور وضعیتِ فعلی ردیف را با قفل می‌خواند و بر اساس این جدول تصمیم می‌گیرد. «clean» یعنی baseVersion برابرِ version فعلی ردیف است، «stale» یعنی نابرابر:

ردیف در سروردرخواست کلاینتنتیجهstatus پاسخبرندهاثر روی ردیف
غایبupsertcreatecreatedکلاینتردیف ساخته می‌شود؛ trigger version جدید می‌دهد
غایب + isDefault: true و یک دیفالتِ زندهٔ دیگرupsertادغام در ردیفِ دیفالتِ موجودcreatedسرورردیفِ جدیدی ساخته نمی‌شود؛ uid/id/version ردیفِ دیفالتِ فعلی (قدیمی‌ترین id) برگردانده می‌شود؛ بقیهٔ دیفالت‌ها demote می‌شوند
غایبdeleteحذفِ ناموجود، بدون خطاdeletedهیچ تغییری نمی‌کند
زندهupsert + baseVersion: nullسرور کپیِ خودش را معتبر می‌داندcreatedسرورفیلدهای ارسالی دور ریخته می‌شوند؛ version فعلی برگردانده می‌شود
زندهupsert + cleanآپدیتupdatedکلاینتفیلدها اعمال می‌شوند؛ version جدید
زندهupsert + staleنوشتهٔ کلاینت اعمال می‌شودconflict (+conflictId)جدیدترین pushوضعیتِ جای‌گذاشته‌شدهٔ سرور به‌عنوان loser snapshot در SyncConflict ذخیره می‌شود
زندهdelete + cleanحذفِ نرمdeletedکلاینتtombstone با version جدید
زندهdelete + staleحذف اعمال می‌شودconflict (+conflictId)حذف‌کنندهردیفِ پیش از حذف bank می‌شود؛ tombstone ساخته می‌شود
tombstoneupsert (هر baseVersion)ویرایشِ بعد از حذف می‌بازدconflict (+conflictId)tombstoneردیف حذف‌شده می‌ماند؛ ویرایشِ کلاینت bank می‌شود
tombstonedeleteایدمپوتنتdeletedهیچ تغییری نمی‌کند
ویرایشِ بدون baseVersion روی ردیفِ موجود

اگر baseVersion: null به ردیفِ زندهٔ موجود بخورد، سرور ساکت کپیِ خودش را برنده می‌داند: status: "created" و version فعلیِ خودش را برمی‌گرداند و فیلدهای ارسالی را دور می‌ریزد. کلاینت باید version برگشتی را بپذیرد و برای فیلدها pull بزند. دلیلش جلوگیری از پاک‌شدنِ ویرایش‌های دستگاه‌های دیگر به‌دستِ کلاینتی است که هنوز snapshot نگرفته — پس هرگز پیش از اولین pull، push نکن (بوت‌استرپ pull-اول است؛ پایین‌تر).

کلندرِ دیفالت و merge سمت سرور

هر کاربر حداکثر یک کلندرِ دیفالت دارد. سرور هنگام ثبت‌نام یک کلندرِ دیفالت با uid سرور-ساخته می‌سازد؛ کلاینتی که آفلاین-اول است ممکن است پیش از اولین pull یک دیفالتِ محلی با uid خودش ساخته باشد. اگر چنین کلاینت کلندرِ دیفالتش را با isDefault: true و uid ناشناخته push کند، سرور آن را در ردیفِ دیفالتِ موجود merge می‌کند (مثل سطرِ «زنده + baseVersion: null»: کپیِ سرور برنده، فیلدهای ارسالی دور ریخته می‌شوند) و uid/id/version همان ردیف را برمی‌گرداند.

کلاینت موظف است uid برگشتی را بپذیرد: اگر uid پاسخ با uid ارسالی فرق داشت، کلندرِ محلی و همهٔ ارجاع‌های FK محلی (calendarUid رویدادها/تسک‌ها) را به uid برگشتی re-key کند و فیلدها را با pull بگیرد. تا پیش از آن، pushِ فرزندان با unknown calendarUid خطای per-item می‌خورد (قابل retry، داده‌ای خراب نمی‌شود). همچنین هر pushی که ردیفی را isDefault: true کند (یا restoreِ snapshot دیفالت)، دیفالت‌های دیگر همان کاربر را خودکار demote می‌کند — نتیجه از طریق pull به همهٔ دستگاه‌ها می‌رسد.

تعارض‌ها

تعارض یک نتیجهٔ per-item است، نه شکستِ درخواست: HTTP 200 برمی‌گردد و فقط همان آیتم status: "conflict" می‌گیرد. دو راهِ تولیدِ تعارض:

  • نوشتهٔ stale روی ردیفِ زنده: جدیدترین push برنده است — نوشتهٔ کلاینت اعمال می‌شود و وضعیتِ جای‌گذاشته‌شدهٔ سرور به‌عنوان loser snapshot در SyncConflict ذخیره می‌شود. هیچ داده‌ای از بین نمی‌رود.
  • ویرایشِ بعد از حذف: tombstone برنده است — ردیف حذف‌شده می‌ماند و ویرایشِ کلاینت bank می‌شود.

هر تعارض یک conflictId بادوام دارد و تا حل‌نشده در GET /api/sync/conflicts (صفحه‌بندی بر اساس id) فهرست می‌شود. حل با POST /api/sync/conflicts/:id/resolve انجام می‌شود:

  • restore — loser snapshot دوباره روی ردیف اعمال می‌شود، tombstone پاک می‌شود و version جدید ساخته می‌شود؛ نتیجه از طریق pull به همهٔ دستگاه‌ها می‌رسد. restore عمداً از مسیر applierهای push رد می‌شود تا بازندهٔ جدیدی bank نکند، پس یک undo تک‌سطحی است.
  • dismiss — تعارض حل‌شده علامت می‌خورد، برنده دست‌نخورده می‌ماند و version جدیدی ساخته نمی‌شود.

چطور کار می‌کند — یک مثالِ کامل

دو دستگاه، A و B، برای یک کاربر. هر دو آفلاین‌اول‌اند. سناریو: A یک رویداد می‌سازد و بعد ویرایشش می‌کند؛ B که آفلاین بوده و نسخهٔ قدیمی‌تری دیده، همان رویداد را ویرایش می‌کند و تعارض تولید می‌شود.

۱) A آفلاین یک رویداد می‌سازد (uid را کلاینت تولید می‌کند) و بعد push می‌زند:

POST /api/sync/push
{
"events": [{
"uid": "11111111-1111-4111-8111-111111111111",
"baseVersion": null,
"op": "upsert",
"calendarUid": "cccc2222-default",
"title": "جلسهٔ تیم",
"startTime": "2026-08-20T09:00:00.000Z",
"endTime": "2026-08-20T10:00:00.000Z"
}]
}
{ "success": true, "data": {
"calendars": [], "events": [
{ "uid": "11111111-1111-4111-8111-111111111111", "id": 501, "version": "10", "status": "created" }
], "tasks": [], "reminders": [], "conflicts": []
}}

A مقدار version: "10" را برای این uid ذخیره می‌کند.

۲) A عنوان را ویرایش می‌کند (به‌روزرسانیِ پاک: baseVersion همان versionی است که قبلاً گرفته):

POST /api/sync/push
{ "events": [{
"uid": "11111111-…", "baseVersion": "10", "op": "upsert", "title": "جلسهٔ هفتگیِ تیم"
}]}
{ "success": true, "data": {
"events": [{ "uid": "11111111-…", "version": "12", "status": "updated" }]
}}

A حالا version: "12" را ذخیره می‌کند. (پرش از ۱۰ به ۱۲ طبیعی است — دنباله روی هر چهار جدول و همهٔ کاربرها مشترک است.)

۳) B آفلاین بوده، آخرین بار version: "10" را دیده، و عنوانِ دیگری می‌نویسد. چون baseVersion با نسخهٔ فعلیِ سرور (12) برابر نیست، push «stale» است: طبق LWW نوشتهٔ B (تازه‌ترِ رسیده) اعمال می‌شود، نسخهٔ جدید می‌گیرد و وضعیتِ جای‌گذاشته‌شده (عنوانِ A روی 12) به‌عنوان loser snapshot در SyncConflict bank می‌شود:

POST /api/sync/push
{ "events": [{
"uid": "11111111-…", "baseVersion": "10", "op": "upsert", "title": "جلسهٔ فوری"
}]}
{ "success": true, "data": {
"calendars": [], "tasks": [], "reminders": [],
"events": [{ "uid": "11111111-…", "id": 501, "version": "13", "status": "conflict", "conflictId": 7 }],
"conflicts": [{
"conflictId": 7,
"entityType": "EVENT",
"entityUid": "11111111-…",
"winnerVersion": "12",
"loserSnapshot": { "uid": "11111111-…", "title": "جلسهٔ هفتگیِ تیم", "version": "12" },
"createdAt": "2026-08-13T12:00:00.000Z",
"resolvedAt": null
}]
}}

ردیف الان عنوانِ B را دارد (version: "13")؛ عنوانِ A در loser snapshot در امان است. B مقدار 13 را ذخیره می‌کند و تعارض را برای تصمیمِ کاربر نشان می‌دهد.

۴) A یک pull می‌زند و نوشتهٔ B را همراه با نسخهٔ جدید می‌بیند:

GET /api/sync/changes?cursor=<cursorِ A>&limit=500
{ "success": true, "data": {
"cursorReset": false, "nextCursor": "<cursorِ جدید>", "hasMore": false,
"calendars": [], "events": [{
"uid": "11111111-…", "version": "13", "deleted": null,
"createdAt": "2026-08-13T11:30:00.000Z", "updatedAt": "2026-08-13T12:05:00.000Z",
"title": "جلسهٔ فوری", "calendarUid": "cccc2222-default"
}], "tasks": [], "reminders": []
}}

۵) کاربر ترجیح می‌دهد عنوانِ هفتگی برنده شود. با restore، loser snapshot دوباره اعمال می‌شود:

POST /api/sync/conflicts/7/resolve
{ "action": "restore" }

restore عنوانِ A («جلسهٔ هفتگیِ تیم») را روی ردیف برمی‌گرداند و یک version تازه (مثلاً 14) می‌سازد؛ pull بعدیِ هر دستگاهی همان نسخه را می‌آورد. (dismiss تعارض را فقط حل‌شده علامت می‌زند: عنوانِ B برنده می‌ماند و version جدیدی ساخته نمی‌شود.)

خطاها

خطاهای سطح درخواست — کل batch را می‌اندازند:

خطاکدشرحبازتلاش؟
changeset خالی یا نامعتبر400هیچ آرایه‌ای پر نیست یا اعتبارسنجی Zod رد می‌کندنه — درخواست را اصلاح کن
بیش از ۱۰۰۰ آیتم در یک موجودیت413دسته را کوچک‌تر کنبله — بعد از شکستنِ دسته
احراز هویت401توکن نامعتبر یا منقضیبله — بعد از refresh
rate limit429۶۰۰ درخواست در ۱۵ دقیقه به‌ازای کاربربله — با احترام به Retry-After

خطاهای per-item — فقط همان آیتم؛ بقیهٔ batch اعمال می‌شوند:

خطایعنیبازتلاش؟
unknown calendarUid / unknown eventUid / unknown taskUid / unknown parentTaskUidمرجعِ زنده‌ای که سرور نمی‌شناسد (والد هنوز sync نشده یا حذف شده)بله — بعد از pull بعدی
calendarUid required for createرویدادِ جدید بدون تقویمنه — آیتم را با calendarUid اصلاح کن
eventUid or taskUid required for createیادآورِ جدید بدون والدنه — آیتم را اصلاح کن
invalid rrule / invalid exdateمقدارِ تکرار نامعتبر استنه — محلی اصلاح کن

آیتمِ صعب‌العبور هرگز batch را نمی‌کشد: هر آیتم transaction خودش را دارد و خطایش فقط در نتیجهٔ همان آیتم (status: "error") ظاهر می‌شود.

قدم‌های legacy

وضعیت فعلی

Event.syncStatus (مقادیر SYNCED/PENDING/CONFLICT) و User.lastSyncTime از پروتکلِ قدیمیِ per-event مانده‌اند. موتورِ همگام‌سازی هرگز آن‌ها را نمی‌خواند یا نمی‌نویسد؛ فقط GET /api/users/stats مقدارشان را می‌خواند و POST /api/users/reset-sync (ابزارِ اشکال‌زدایی) آن‌ها را بازتنظیم می‌کند. بخشی از جریانِ version/cursor نیستند و کلاینتِ جدید به آن‌ها نیازی ندارد.

کلاینت چطور از sync استفاده کند

در حالتِ پایدار حلقه کوتاه است: اول push، بعد pull. اما بوت‌استرپ برعکس است — pull اول:

  1. بوت‌استرپ (اولین اجرا، یا cursor گم‌شده): اول GET /api/sync/changes را بدون cursor بزن. پاسخ cursorReset: true و یک snapshot کامل است (شامل تقویمِ پیش‌فرض «تقویم اصلی» که هنگام verify-otp ساخته می‌شود). همه را با uid ذخیره کن و nextCursor را نگه دار؛ بعد outbox را push کن — با baseVersion: null برای آیتم‌هایی که فقط محلی‌اند.
  2. حالتِ پایدار: اول push، بعد pull.

چرا pull اول؟ push پیش از اولین pull یعنی baseVersion: null روی ردیف‌هایی که شاید سرور از قبل دارد — و طبق ماتریس، سرور آن نوشته را ساکت دور می‌ریزد. مرجع‌های FK هم باید اول از سرور دیده شوند.

قواعدِ بار-محور:

  • push آیدمپوتنت است روی { uid, baseVersion }. retry بی‌خطر است؛ تکرارِ یک push رکوردِ جدید نمی‌سازد.
  • version را ذخیره کن. push بعدیِ همان رکورد، versionِ برگشتی را به‌عنوان baseVersion می‌فرستد. برای create، baseVersion: null.
  • cursor یک رشتهٔ مبهم است. دستکاری‌اش نکن؛ null یا گم‌شده یعنی pull کامل. اگر خراب شده یا متعلق به کاربرِ دیگری باشد، سرور cursorReset: true می‌فرستد و یک pull کامل برمی‌گرداند.
  • ترتیبِ داخلِ یک push: calendars → events → tasks → reminders. والد قبل از فرزند (مثلاً در یک batch، parentTaskUid باید قبل از subtaskِش بیاید).
  • tombstone: اگر در pull deleted != null بود، رکورد را محلی هم حذف کن.
  • تکرارِ رویداد: rrule رشتهٔ RFC 5545 بدون پیشوندِ RRULE: است (مثل FREQ=WEEKLY;COUNT=10;WKST=SU;BYDAY=MO) و exdate فهرستِ کاما-جداشدهٔ ISO-8601 از شروعِ رخدادهای حذف‌شده از سری. مقدارِ نامعتبر در push → per-item status: "error" با invalid rrule/invalid exdate. جزئیات در رویدادها.
  • تعارض: اگر یک آیتم status: "conflict" برگرداند، version برگشتی را ذخیره کن و mutation را مصرف‌شده بدان. در تعارضِ stale نوشتهٔ شما روی ردیف نشسته و وضعیتِ جای‌گذاشته‌شده در loser snapshot در امان است؛ در ویرایشِ بعد از حذف، ردیف حذف‌شده مانده و ویرایشِ شما bank شده. هر دو را برای تصمیمِ کاربر (/api/sync/conflicts، سپس restore/dismiss) نشان بده.
  • خطا: status: "error" یعنی آن آیتم (نه کلِ batch) خراب است. با یک سقفِ تلاش (مثلاً ۵ بار) retry کن؛ آیتمِ صعب‌العبور را جدا کن تا outbox را مسموم نکند.
  • محرک‌ها: هر نوشتنِ محلی (با یک debounce کوتاه)، رویداد online، visibilitychange، و یک تایمرِ دوره‌ای — همه باید همان حلقهٔ push()pull() را صدا بزنند؛ در کلاینتی که push notification دارد (FCM)، نوتیفِ «داده تغییر کرد» هم همین حلقه را صدا می‌زند.

پیاده‌سازی‌های مرجع: کلاینت وب و برای اتصالِ اندروید، کلاینت اندروید و کلاینت Kotlin تولیدشده.

چرا فقط از طریق sync؟

هر کلاینتی که حالتِ محلی نگه می‌دارد (اپ اندروید و اپ وب) Calendar، Event، Task و Reminder را فقط از طریق /api/sync/* می‌خواند و می‌نویسد. مسیرهای مستقلِ REST برای Calendar و Event و Reminder — یعنی /api/calendars، /api/events و /api/reminders — حذف شده‌اند.

دلیلش فنی است: همگام‌سازی روی جفتِ { uid, baseVersion } بنا شده و uid را کلاینت می‌سازد. یک مسیرِ REST جدا که این جفت را دور بزند، رکوردی تولید می‌کرد که سرور نسخه‌اش را نمی‌شناخت و در همگام‌سازی نادیده گرفته می‌شد. به‌جای دو سطحِ دسترسیِ موازی، آن مسیرها حذف شدند و همگام‌سازی تنها راه ماند. (جزئیاتِ مدلِ رویداد در رویدادها.)

Task هم مثلِ بقیه فقط از طریق /api/sync همگام می‌شود: سمتِ سرور applyTaskItem آن را اعمال می‌کند، در دلتای pull برمی‌گردد، و cursor یک واترمارکِ tasks دارد. مسیرِ مستقلِ /api/tasks (مثلِ /api/events، /api/calendars و /api/reminders) حذف شده است. جزئیات در وظایف.

موتورِ آفلاینِ اپ وب (apps/calendar-web)

اپ وب یک کلاینتِ آفلاین‌اول است. UI هرگز مستقیم به شبکه نمی‌زند؛ از IndexedDB می‌خواند و در آن می‌نویسد، و موتورِ sync در پس‌زمینه با سرور آشتی می‌کند. Calendar، Event، Task و Reminder همگی همین مسیر را طی می‌کنند: نوشتن در Dexie + یک ردیف در outbox، سپس push/pull از /api/sync/*.

قطعهمسیرنقش
store محلیsrc/lib/db/schema.tsDexie (IndexedDB): جدول‌های calendars/events/reminders/tasks + pendingMutations (outbox) + syncCursor + conflicts.
repositorysrc/lib/repository/dexieRepository.tsخواندن/نوشتنِ typed + observe() روی liveQuery — UI با هر نوشتنِ محلی بلافاصله re-render می‌شود.
موتور syncsrc/lib/sync/syncEngine.tspush()pull()refreshConflicts().
انتخابِ رهبرsrc/lib/sync/leaderElection.tsWeb Locks API: فقط یک تب sync می‌کند.
لایهٔ HTTPsrc/lib/sync/syncApi.tsتنها جایی در کلِ اپ وب که /api/sync/* را صدا می‌زند.

رفتارهای کلیدی:

  • outbox: هر نوشتنِ محلی یک ردیف در pendingMutations می‌گذارد ({uid, baseVersion, op, payload, status, attempts}). push() این‌ها را دسته‌ای می‌فرستد و بر اساس نتیجهٔ هر آیتم، پاک یا requeue می‌کند (MAX_ATTEMPTS = 5؛ بعد از آن آیتم در failed پارک می‌شود).
  • trigger‌ها: syncNow() با چهار محرک اجرا می‌شود — رویداد online، visibilitychange، یک تایمرِ ۶۰ثانیه‌ای، و یک kick() با debounce ۵۰۰ میلی‌ثانیه بعد از هر نوشتنِ محلی. یک نگهبانِ single-flight هم از هم‌پوشانیِ این محرک‌ها جلوگیری می‌کند.
  • بازیابی از crash: start() هر mutationِ گیرکردهٔ in_flight را به pending برمی‌گرداند (push به‌خاطر gate روی baseVersion ایدمپوتنت است) و به آیتم‌های failed یک بودجهٔ تلاشِ تازه می‌دهد تا ویرایش‌های کاربر برای همیشه گیر نکنند.
  • logout: wipeDb() کلِ دیتابیسِ محلی را پاک می‌کند — یک الزامِ امنیتی برای دستگاه‌های مشترک.
  • تعارض‌ها: features/sync/ یک ConflictBanner و ConflictList دارد که به /api/sync/conflicts وصل‌اند.

جزئیاتِ کاملِ طراحی در spec آفلاین‌اولِ وب آمده، و برای مدل کامل و شکلِ دقیقِ request/response به spec طراحی همگام‌سازی و مرجع API مراجعه کنید.