همگامسازی (آفلاین)
همگامسا زی یک مدل آفلاین، چنددستگاهه و دوطرفه است که بدون از دست رفتن داده
کار میکند. چهار موجودیت از آن عبور میکنند: 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.
یک نمونهٔ مشخص:
- کلاینت رکوردی با
version: "23"دارد و کاربر عنوانش را ویرایش میکند. - کلاینت در push فقط فیلدهای تغییرکرده بههمراه
baseVersion: "23"میفرستد. - سرور میبیند ردیف هنوز روی
23است (clean)، فیلدها را اعمال میکند و trigger نسخهٔ"31"میدهد. - پاسخ
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 بهازای کاربر انجام میشود و برای هر نوع چنین میخواند:
SELECT … WHERE "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یاdelete)،baseVersionو فقط فیلدهای تغییرکرده. - ترتیبِ اعمال:
calendars → events → tasks → reminders، و هر آیتم در transaction خودش اعمال میشود (قفلِ کاربر +FOR UPDATEروی ردیف). نتیجه: والد و فرزند میتوانند در یک درخواست بیایند — والدی که زودتر اعمال شده، مرجعِ FK فرزند (calendarUid،parentTaskUid، …) را در همان batch حل میکند.
ماتریس تصمیم
سرور وضعیتِ فعلی ردیف را با قفل میخواند و بر اساس این جدول تصمیم میگیرد.
«clean» یعنی baseVersion برابرِ version فعلی ردیف است، «stale» یعنی نابرابر:
| ردیف در سرور | درخواست کلاینت | نتیجه | status پاسخ | برنده | اثر روی ردیف |
|---|---|---|---|---|---|
| غایب | upsert | create | created | کلاینت | ردیف ساخته میشود؛ 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 ساخته میشود |
| tombstone | upsert (هر baseVersion) | ویرایشِ بعد از حذف میبازد | conflict (+conflictId) | tombstone | ردیف حذفشده میماند؛ ویرایشِ کلاینت bank میشود |
| tombstone | delete | ایدمپوتنت | deleted | — | هیچ تغییری نمیکند |
اگر baseVersion: null به ردیفِ زندهٔ موجود بخورد، سرور ساکت کپیِ خودش را برنده
میداند: status: "created" و version فعلیِ خودش را برمیگرداند و فیلدهای
ارسالی را دور میریزد. کلاینت باید version برگشتی را بپذیرد و برای فیلدها
pull بزند. دلیلش جلوگیری از پاکشدنِ ویرایشهای دستگاههای دیگر بهدستِ کلاینتی
است که هنوز snapshot نگرفته — پس هرگز پیش از اولین pull، push نکن (بوتاسترپ
pull-اول است؛ پایینتر).
هر کاربر حداکثر یک کلندرِ دیفالت دارد. سرور هنگام ثبتنام یک کلندرِ دیفالت با
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 limit | 429 | ۶۰۰ درخواست در ۱۵ دقیقه بهازای کاربر | بله — با احترام به 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 اول:
- بوتاسترپ (اولین اجرا، یا cursor گمشده): اول
GET /api/sync/changesرا بدون cursor بزن. پاسخcursorReset: trueو یک snapshot کامل است (شامل تقویمِ پیشفرض «تقویم اصلی» که هنگام verify-otp ساخته میشود). همه را باuidذخیره کن وnextCursorرا نگه دار؛ بعد outbox را push کن — باbaseVersion: nullبرای آیتمهایی که فقط محلیاند. - حالتِ پایدار: اول 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-itemstatus: "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.ts | Dexie (IndexedDB): جدولهای calendars/events/reminders/tasks + pendingMutations (outbox) + syncCursor + conflicts. |
| repository | src/lib/repository/dexieRepository.ts | خواندن/نوشتنِ typed + observe() روی liveQuery — UI با هر نوشتنِ محلی بلافاصله re-render میشود. |
| موتور sync | src/lib/sync/syncEngine.ts | push() → pull() → refreshConflicts(). |
| انتخابِ رهبر | src/lib/sync/leaderElection.ts | Web Locks API: فقط یک تب sync میکند. |
| لایهٔ HTTP | src/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 مراجعه کنید.