* feat(sync): cross-device dictionary sync Custom MDict / StarDict / DICT / SLOB dictionaries now sync across signed-in devices via the replica layer. - Store mutations publish replica rows with field-level LWW + tombstones. - Re-importing the same content (renamed or after delete) preserves the user's label and reincarnates the server row instead of duplicating. - Manifest commits after binary upload so other devices never see a row whose binaries aren't on cloud storage yet. - Pull-side orchestrator creates a placeholder dict, queues the binaries via TransferManager, and clears the unavailable flag on completion. - Toast copy branches by transfer kind so dict uploads don't read "Book uploaded". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): boot pull and binary download path - Defer the boot pull until TransferManager is initialized so download enqueues aren't dropped. - Auto-persist the local dict store after applyRemoteDictionary; otherwise the next loadCustomDictionaries wipes the in-memory rows. - Boot pull passes since=null so a device whose cursor advanced past unpersisted rows can still recover. - Skip pulling when not authenticated instead of logging "SyncError: Not authenticated" on every boot of a signed-out device. - downloadReplicaFile resolves the destination against the kind's base dir; binaries previously landed at the literal lfp and openFile then failed with "File not found". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(sync): per-page useReplicaPull hook Lifts the boot-time pull out of EnvContext into a hook each page mounts for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }). Library page and the shared Reader component opt in. The hook fires 10s after page load (so feature mounts hydrate first), dedups per-kind across navigation, and releases the slot on failure so a later mount can retry. Future kinds plug into the hook's per-kind switch. Also closes two refresh-loop bugs: - Hydrate the dict store from settings BEFORE the apply loop, so the auto-persist doesn't clobber persisted rows that the in-memory store hadn't yet read. Library-page refresh was the visible victim. - Skip the download queue when every manifest file is already on disk under the resolved bundle dir. Refreshing is a no-op; partial- download recovery still queues because some files would be missing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1428,5 +1428,11 @@
|
||||
"Edit Dictionary": "تعديل القاموس",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "تستخدم حزم MDict ملفات .mdx؛ ملفات .mdd و .css المرافقة اختيارية.",
|
||||
"Dictionary name": "اسم القاموس",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "لا يمكن تشغيل هذا الصوت هنا — يستخدم القاموس تنسيقًا قديمًا. جرّب قاموسًا يستخدم صوت Opus أو MP3 أو WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "لا يمكن تشغيل هذا الصوت هنا — يستخدم القاموس تنسيقًا قديمًا. جرّب قاموسًا يستخدم صوت Opus أو MP3 أو WAV.",
|
||||
"Dictionary uploaded: {{title}}": "تم رفع القاموس: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "تم تنزيل القاموس: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "تم حذف النسخة السحابية للقاموس: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "فشل رفع القاموس: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "فشل تنزيل القاموس: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "فشل حذف النسخة السحابية للقاموس: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1332,5 +1332,11 @@
|
||||
"Edit Dictionary": "অভিধান সম্পাদনা করুন",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict বান্ডেল .mdx ফাইল ব্যবহার করে; সহগামী .mdd ও .css ফাইল ঐচ্ছিক।",
|
||||
"Dictionary name": "অভিধানের নাম",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "এই অডিও এখানে চালানো যাচ্ছে না — অভিধানটি একটি পুরনো ফর্ম্যাট ব্যবহার করছে। Opus, MP3 বা WAV অডিও সহ একটি ব্যবহার করে দেখুন।"
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "এই অডিও এখানে চালানো যাচ্ছে না — অভিধানটি একটি পুরনো ফর্ম্যাট ব্যবহার করছে। Opus, MP3 বা WAV অডিও সহ একটি ব্যবহার করে দেখুন।",
|
||||
"Dictionary uploaded: {{title}}": "অভিধান আপলোড করা হয়েছে: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "অভিধান ডাউনলোড করা হয়েছে: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "অভিধানের ক্লাউড কপি মুছে ফেলা হয়েছে: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "অভিধান আপলোড করতে ব্যর্থ: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "অভিধান ডাউনলোড করতে ব্যর্থ: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "অভিধানের ক্লাউড কপি মুছতে ব্যর্থ: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1308,5 +1308,11 @@
|
||||
"Edit Dictionary": "ཚིག་མཛོད་བཟོ་བཅོས།",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict གི་ཡིག་ཆས་ཚོགས་ཀྱིས་ .mdx ཡིག་ཆ་སྤྱོད། སྦྲེལ་མཐུན་ .mdd དང་ .css ཡིག་ཆ་ཡིན་ན་འདེམས་ཆོག",
|
||||
"Dictionary name": "ཚིག་མཛོད་མིང་།",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "སྒྲ་འདི་འདིར་སྒྲོག་མི་ཐུབ — ཚིག་མཛོད་འདིས་རྩིས་མེད་པའི་རྣམ་པ་སྤྱོད། Opus, MP3, ཡང་ན་ WAV གི་སྒྲ་ལྡན་པ་ཞིག་ལ་འདེམས་སྡུར་གྱིས།"
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "སྒྲ་འདི་འདིར་སྒྲོག་མི་ཐུབ — ཚིག་མཛོད་འདིས་རྩིས་མེད་པའི་རྣམ་པ་སྤྱོད། Opus, MP3, ཡང་ན་ WAV གི་སྒྲ་ལྡན་པ་ཞིག་ལ་འདེམས་སྡུར་གྱིས།",
|
||||
"Dictionary uploaded: {{title}}": "ཚིག་མཛོད་སྤར་འཇུག་ཟིན། {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "ཚིག་མཛོད་ཕབ་ལེན་ཟིན། {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "སྤྲིན་ཐོག་གི་ཚིག་མཛོད་འདྲ་བཤུས་སུབ་ཟིན། {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "ཚིག་མཛོད་སྤར་འཇུག་ལ་ཐུབ་མ་སོང་། {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "ཚིག་མཛོད་ཕབ་ལེན་ལ་ཐུབ་མ་སོང་། {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "སྤྲིན་ཐོག་གི་ཚིག་མཛོད་འདྲ་བཤུས་སུབ་ཐུབ་མ་སོང་། {{title}}"
|
||||
}
|
||||
|
||||
@@ -1332,5 +1332,11 @@
|
||||
"Edit Dictionary": "Wörterbuch bearbeiten",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict-Bundles verwenden .mdx-Dateien; begleitende .mdd- und .css-Dateien sind optional.",
|
||||
"Dictionary name": "Wörterbuchname",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Diese Audio kann hier nicht abgespielt werden — das Wörterbuch verwendet ein veraltetes Format. Probieren Sie ein Wörterbuch mit Opus-, MP3- oder WAV-Audio."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Diese Audio kann hier nicht abgespielt werden — das Wörterbuch verwendet ein veraltetes Format. Probieren Sie ein Wörterbuch mit Opus-, MP3- oder WAV-Audio.",
|
||||
"Dictionary uploaded: {{title}}": "Wörterbuch hochgeladen: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Wörterbuch heruntergeladen: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Cloud-Kopie des Wörterbuchs gelöscht: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Fehler beim Hochladen des Wörterbuchs: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Fehler beim Herunterladen des Wörterbuchs: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Fehler beim Löschen der Cloud-Kopie des Wörterbuchs: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1332,5 +1332,11 @@
|
||||
"Edit Dictionary": "Επεξεργασία λεξικού",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Τα πακέτα MDict χρησιμοποιούν αρχεία .mdx· τα συνοδευτικά αρχεία .mdd και .css είναι προαιρετικά.",
|
||||
"Dictionary name": "Όνομα λεξικού",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Αυτός ο ήχος δεν μπορεί να αναπαραχθεί εδώ — το λεξικό χρησιμοποιεί ξεπερασμένη μορφή. Δοκιμάστε ένα λεξικό με ήχο Opus, MP3 ή WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Αυτός ο ήχος δεν μπορεί να αναπαραχθεί εδώ — το λεξικό χρησιμοποιεί ξεπερασμένη μορφή. Δοκιμάστε ένα λεξικό με ήχο Opus, MP3 ή WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Το λεξικό μεταφορτώθηκε: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Το λεξικό κατέβηκε: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Διαγράφηκε το αντίγραφο cloud του λεξικού: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Αποτυχία μεταφόρτωσης λεξικού: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Αποτυχία λήψης λεξικού: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Αποτυχία διαγραφής αντιγράφου cloud του λεξικού: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1356,5 +1356,11 @@
|
||||
"Edit Dictionary": "Editar diccionario",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Los paquetes MDict usan archivos .mdx; los archivos .mdd y .css complementarios son opcionales.",
|
||||
"Dictionary name": "Nombre del diccionario",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este audio no se puede reproducir aquí — el diccionario usa un formato obsoleto. Prueba uno con audio Opus, MP3 o WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este audio no se puede reproducir aquí — el diccionario usa un formato obsoleto. Prueba uno con audio Opus, MP3 o WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Diccionario subido: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Diccionario descargado: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Copia en la nube del diccionario eliminada: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "No se pudo subir el diccionario: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "No se pudo descargar el diccionario: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "No se pudo eliminar la copia en la nube del diccionario: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1332,5 +1332,11 @@
|
||||
"Edit Dictionary": "ویرایش فرهنگنامه",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "بستههای MDict از فایلهای .mdx استفاده میکنند؛ فایلهای همراه .mdd و .css اختیاری هستند.",
|
||||
"Dictionary name": "نام فرهنگنامه",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "این صدا اینجا قابل پخش نیست — فرهنگنامه از قالبی قدیمی استفاده میکند. فرهنگنامهای با صدای Opus، MP3 یا WAV را امتحان کنید."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "این صدا اینجا قابل پخش نیست — فرهنگنامه از قالبی قدیمی استفاده میکند. فرهنگنامهای با صدای Opus، MP3 یا WAV را امتحان کنید.",
|
||||
"Dictionary uploaded: {{title}}": "فرهنگ لغت بارگذاری شد: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "فرهنگ لغت دانلود شد: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "نسخهٔ ابری فرهنگ لغت حذف شد: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "بارگذاری فرهنگ لغت ناموفق بود: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "دانلود فرهنگ لغت ناموفق بود: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "حذف نسخهٔ ابری فرهنگ لغت ناموفق بود: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1356,5 +1356,11 @@
|
||||
"Edit Dictionary": "Modifier le dictionnaire",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Les bundles MDict utilisent des fichiers .mdx ; les fichiers .mdd et .css associés sont facultatifs.",
|
||||
"Dictionary name": "Nom du dictionnaire",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Cet audio ne peut pas être lu ici — le dictionnaire utilise un format obsolète. Essayez-en un avec un audio Opus, MP3 ou WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Cet audio ne peut pas être lu ici — le dictionnaire utilise un format obsolète. Essayez-en un avec un audio Opus, MP3 ou WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Dictionnaire importé : {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Dictionnaire téléchargé : {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Copie cloud du dictionnaire supprimée : {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Échec de l'envoi du dictionnaire : {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Échec du téléchargement du dictionnaire : {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Échec de la suppression de la copie cloud du dictionnaire : {{title}}"
|
||||
}
|
||||
|
||||
@@ -1356,5 +1356,11 @@
|
||||
"Edit Dictionary": "ערוך מילון",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "חבילות MDict משתמשות בקובצי .mdx; קובצי .mdd ו‑.css נלווים הם אופציונליים.",
|
||||
"Dictionary name": "שם המילון",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "לא ניתן להשמיע את השמע הזה כאן — המילון משתמש בפורמט מיושן. נסה מילון עם שמע Opus, MP3 או WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "לא ניתן להשמיע את השמע הזה כאן — המילון משתמש בפורמט מיושן. נסה מילון עם שמע Opus, MP3 או WAV.",
|
||||
"Dictionary uploaded: {{title}}": "המילון הועלה: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "המילון הורד: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "עותק הענן של המילון נמחק: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "העלאת המילון נכשלה: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "הורדת המילון נכשלה: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "מחיקת עותק הענן של המילון נכשלה: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1332,5 +1332,11 @@
|
||||
"Edit Dictionary": "शब्दकोश संपादित करें",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict बंडल .mdx फ़ाइलों का उपयोग करते हैं; साथ की .mdd और .css फ़ाइलें वैकल्पिक हैं।",
|
||||
"Dictionary name": "शब्दकोश का नाम",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "यह ऑडियो यहाँ नहीं चलाया जा सकता — शब्दकोश पुराने प्रारूप का उपयोग करता है। Opus, MP3 या WAV ऑडियो वाला कोई आज़माएँ।"
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "यह ऑडियो यहाँ नहीं चलाया जा सकता — शब्दकोश पुराने प्रारूप का उपयोग करता है। Opus, MP3 या WAV ऑडियो वाला कोई आज़माएँ।",
|
||||
"Dictionary uploaded: {{title}}": "शब्दकोश अपलोड हो गया: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "शब्दकोश डाउनलोड हो गया: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "शब्दकोश की क्लाउड कॉपी हटाई गई: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "शब्दकोश अपलोड करने में विफल: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "शब्दकोश डाउनलोड करने में विफल: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "शब्दकोश की क्लाउड कॉपी हटाने में विफल: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1332,5 +1332,11 @@
|
||||
"Edit Dictionary": "Szótár szerkesztése",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Az MDict csomagok .mdx fájlokat használnak; a kísérő .mdd és .css fájlok opcionálisak.",
|
||||
"Dictionary name": "Szótár neve",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Ez a hang itt nem játszható le — a szótár elavult formátumot használ. Próbálj olyat, amely Opus, MP3 vagy WAV hangot tartalmaz."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Ez a hang itt nem játszható le — a szótár elavult formátumot használ. Próbálj olyat, amely Opus, MP3 vagy WAV hangot tartalmaz.",
|
||||
"Dictionary uploaded: {{title}}": "Szótár feltöltve: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Szótár letöltve: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "A szótár felhőbeli másolata törölve: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Nem sikerült feltölteni a szótárat: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Nem sikerült letölteni a szótárat: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Nem sikerült törölni a szótár felhőbeli másolatát: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1308,5 +1308,11 @@
|
||||
"Edit Dictionary": "Edit Kamus",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Paket MDict menggunakan berkas .mdx; berkas .mdd dan .css pendamping bersifat opsional.",
|
||||
"Dictionary name": "Nama kamus",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Audio ini tidak dapat diputar di sini — kamus menggunakan format usang. Coba kamus dengan audio Opus, MP3, atau WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Audio ini tidak dapat diputar di sini — kamus menggunakan format usang. Coba kamus dengan audio Opus, MP3, atau WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Kamus diunggah: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Kamus diunduh: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Salinan cloud kamus dihapus: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Gagal mengunggah kamus: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Gagal mengunduh kamus: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Gagal menghapus salinan cloud kamus: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1356,5 +1356,11 @@
|
||||
"Edit Dictionary": "Modifica dizionario",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "I pacchetti MDict usano file .mdx; i file .mdd e .css di accompagnamento sono facoltativi.",
|
||||
"Dictionary name": "Nome del dizionario",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Questo audio non può essere riprodotto qui — il dizionario usa un formato obsoleto. Prova un dizionario con audio Opus, MP3 o WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Questo audio non può essere riprodotto qui — il dizionario usa un formato obsoleto. Prova un dizionario con audio Opus, MP3 o WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Dizionario caricato: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Dizionario scaricato: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Copia cloud del dizionario eliminata: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Caricamento del dizionario non riuscito: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Download del dizionario non riuscito: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Eliminazione della copia cloud del dizionario non riuscita: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1308,5 +1308,11 @@
|
||||
"Edit Dictionary": "辞書を編集",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict バンドルは .mdx ファイルを使用します。付属の .mdd および .css ファイルは任意です。",
|
||||
"Dictionary name": "辞書名",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "この音声はここで再生できません — 辞書が古い形式を使用しています。Opus、MP3、または WAV 音声を使用する辞書を試してください。"
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "この音声はここで再生できません — 辞書が古い形式を使用しています。Opus、MP3、または WAV 音声を使用する辞書を試してください。",
|
||||
"Dictionary uploaded: {{title}}": "辞書をアップロードしました: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "辞書をダウンロードしました: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "辞書のクラウドコピーを削除しました: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "辞書のアップロードに失敗しました: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "辞書のダウンロードに失敗しました: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "辞書のクラウドコピーの削除に失敗しました: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1308,5 +1308,11 @@
|
||||
"Edit Dictionary": "사전 편집",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict 번들은 .mdx 파일을 사용합니다. 동반된 .mdd 및 .css 파일은 선택 사항입니다.",
|
||||
"Dictionary name": "사전 이름",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "여기서는 이 오디오를 재생할 수 없습니다 — 사전이 오래된 형식을 사용합니다. Opus, MP3 또는 WAV 오디오 사전을 사용해 보세요."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "여기서는 이 오디오를 재생할 수 없습니다 — 사전이 오래된 형식을 사용합니다. Opus, MP3 또는 WAV 오디오 사전을 사용해 보세요.",
|
||||
"Dictionary uploaded: {{title}}": "사전 업로드 완료: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "사전 다운로드 완료: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "사전의 클라우드 사본을 삭제했습니다: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "사전 업로드 실패: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "사전 다운로드 실패: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "사전의 클라우드 사본 삭제 실패: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1308,5 +1308,11 @@
|
||||
"Edit Dictionary": "Sunting Kamus",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Bungkusan MDict menggunakan fail .mdx; fail .mdd dan .css yang menyertainya adalah pilihan.",
|
||||
"Dictionary name": "Nama kamus",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Audio ini tidak boleh dimainkan di sini — kamus menggunakan format lapuk. Cuba kamus dengan audio Opus, MP3 atau WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Audio ini tidak boleh dimainkan di sini — kamus menggunakan format lapuk. Cuba kamus dengan audio Opus, MP3 atau WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Kamus dimuat naik: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Kamus dimuat turun: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Salinan awan kamus dipadam: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Gagal memuat naik kamus: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Gagal memuat turun kamus: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Gagal memadam salinan awan kamus: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1332,5 +1332,11 @@
|
||||
"Edit Dictionary": "Woordenboek bewerken",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict-bundels gebruiken .mdx-bestanden; bijbehorende .mdd- en .css-bestanden zijn optioneel.",
|
||||
"Dictionary name": "Naam van woordenboek",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Deze audio kan hier niet worden afgespeeld — het woordenboek gebruikt een verouderd formaat. Probeer een woordenboek met Opus-, MP3- of WAV-audio."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Deze audio kan hier niet worden afgespeeld — het woordenboek gebruikt een verouderd formaat. Probeer een woordenboek met Opus-, MP3- of WAV-audio.",
|
||||
"Dictionary uploaded: {{title}}": "Woordenboek geüpload: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Woordenboek gedownload: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Cloudkopie van het woordenboek verwijderd: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Uploaden van woordenboek mislukt: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Downloaden van woordenboek mislukt: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Verwijderen van cloudkopie van het woordenboek mislukt: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1380,5 +1380,11 @@
|
||||
"Edit Dictionary": "Edytuj słownik",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Pakiety MDict używają plików .mdx; towarzyszące pliki .mdd i .css są opcjonalne.",
|
||||
"Dictionary name": "Nazwa słownika",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Tego dźwięku nie można odtworzyć tutaj — słownik używa przestarzałego formatu. Spróbuj słownika z dźwiękiem Opus, MP3 lub WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Tego dźwięku nie można odtworzyć tutaj — słownik używa przestarzałego formatu. Spróbuj słownika z dźwiękiem Opus, MP3 lub WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Słownik przesłany: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Słownik pobrany: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Usunięto kopię słownika w chmurze: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Nie udało się przesłać słownika: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Nie udało się pobrać słownika: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Nie udało się usunąć kopii słownika w chmurze: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1356,5 +1356,11 @@
|
||||
"Edit Dictionary": "Editar dicionário",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Os pacotes MDict usam arquivos .mdx; os arquivos .mdd e .css que os acompanham são opcionais.",
|
||||
"Dictionary name": "Nome do dicionário",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este áudio não pode ser reproduzido aqui — o dicionário usa um formato obsoleto. Tente um dicionário com áudio Opus, MP3 ou WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este áudio não pode ser reproduzido aqui — o dicionário usa um formato obsoleto. Tente um dicionário com áudio Opus, MP3 ou WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Dicionário carregado: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Dicionário baixado: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Cópia na nuvem do dicionário excluída: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Falha ao carregar o dicionário: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Falha ao baixar o dicionário: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Falha ao excluir a cópia na nuvem do dicionário: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1356,5 +1356,11 @@
|
||||
"Edit Dictionary": "Editar dicionário",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Os pacotes MDict usam ficheiros .mdx; os ficheiros .mdd e .css que os acompanham são opcionais.",
|
||||
"Dictionary name": "Nome do dicionário",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este áudio não pode ser reproduzido aqui — o dicionário usa um formato obsoleto. Experimenta um dicionário com áudio Opus, MP3 ou WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este áudio não pode ser reproduzido aqui — o dicionário usa um formato obsoleto. Experimenta um dicionário com áudio Opus, MP3 ou WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Dicionário carregado: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Dicionário transferido: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Cópia na nuvem do dicionário eliminada: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Falha ao carregar o dicionário: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Falha ao transferir o dicionário: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Falha ao eliminar a cópia na nuvem do dicionário: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1356,5 +1356,11 @@
|
||||
"Edit Dictionary": "Editează dicționarul",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Pachetele MDict folosesc fișiere .mdx; fișierele .mdd și .css însoțitoare sunt opționale.",
|
||||
"Dictionary name": "Numele dicționarului",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Acest audio nu poate fi redat aici — dicționarul folosește un format învechit. Încearcă unul cu audio Opus, MP3 sau WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Acest audio nu poate fi redat aici — dicționarul folosește un format învechit. Încearcă unul cu audio Opus, MP3 sau WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Dicționar încărcat: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Dicționar descărcat: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Copia din cloud a dicționarului a fost ștearsă: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Încărcarea dicționarului a eșuat: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Descărcarea dicționarului a eșuat: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Ștergerea copiei din cloud a dicționarului a eșuat: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1380,5 +1380,11 @@
|
||||
"Edit Dictionary": "Редактировать словарь",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Пакеты MDict используют файлы .mdx; сопутствующие файлы .mdd и .css необязательны.",
|
||||
"Dictionary name": "Название словаря",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Этот звук невозможно воспроизвести здесь — словарь использует устаревший формат. Попробуйте словарь с аудио Opus, MP3 или WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Этот звук невозможно воспроизвести здесь — словарь использует устаревший формат. Попробуйте словарь с аудио Opus, MP3 или WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Словарь загружен: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Словарь скачан: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Облачная копия словаря удалена: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Не удалось загрузить словарь: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Не удалось скачать словарь: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Не удалось удалить облачную копию словаря: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1332,5 +1332,11 @@
|
||||
"Edit Dictionary": "ශබ්දකෝෂය සංස්කරණය කරන්න",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict පැකේජ .mdx ගොනු භාවිතා කරයි; සහායක .mdd සහ .css ගොනු විකල්පීය වේ.",
|
||||
"Dictionary name": "ශබ්දකෝෂයේ නම",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "මෙම ශබ්දය මෙහි වාදනය කළ නොහැක — ශබ්දකෝෂය යල් පැන ගිය ආකෘතියක් භාවිතා කරයි. Opus, MP3 හෝ WAV ශබ්ද සහිත එකක් උත්සාහ කරන්න."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "මෙම ශබ්දය මෙහි වාදනය කළ නොහැක — ශබ්දකෝෂය යල් පැන ගිය ආකෘතියක් භාවිතා කරයි. Opus, MP3 හෝ WAV ශබ්ද සහිත එකක් උත්සාහ කරන්න.",
|
||||
"Dictionary uploaded: {{title}}": "ශබ්දකෝෂය උඩුගත කළා: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "ශබ්දකෝෂය බාගත කළා: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "ශබ්දකෝෂයේ ක්ලවුඩ් පිටපත මකා දැම්මා: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "ශබ්දකෝෂය උඩුගත කිරීමට අසමත් වුණා: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "ශබ්දකෝෂය බාගත කිරීමට අසමත් වුණා: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "ශබ්දකෝෂයේ ක්ලවුඩ් පිටපත මකා දැමීමට අසමත් වුණා: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1380,5 +1380,11 @@
|
||||
"Edit Dictionary": "Uredi slovar",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Paketi MDict uporabljajo datoteke .mdx; spremne datoteke .mdd in .css so neobvezne.",
|
||||
"Dictionary name": "Ime slovarja",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Tega zvoka tukaj ni mogoče predvajati — slovar uporablja zastarelo obliko. Poskusite slovar z zvokom Opus, MP3 ali WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Tega zvoka tukaj ni mogoče predvajati — slovar uporablja zastarelo obliko. Poskusite slovar z zvokom Opus, MP3 ali WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Slovar naložen: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Slovar prenesen: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Kopija slovarja v oblaku izbrisana: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Nalaganje slovarja ni uspelo: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Prenos slovarja ni uspel: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Brisanje kopije slovarja v oblaku ni uspelo: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1332,5 +1332,11 @@
|
||||
"Edit Dictionary": "Redigera ordbok",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict-paket använder .mdx-filer; medföljande .mdd- och .css-filer är valfria.",
|
||||
"Dictionary name": "Ordbokens namn",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Det här ljudet kan inte spelas upp här — ordboken använder ett föråldrat format. Prova en ordbok med Opus-, MP3- eller WAV-ljud."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Det här ljudet kan inte spelas upp här — ordboken använder ett föråldrat format. Prova en ordbok med Opus-, MP3- eller WAV-ljud.",
|
||||
"Dictionary uploaded: {{title}}": "Ordlista uppladdad: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Ordlista nedladdad: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Molnkopia av ordlistan borttagen: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Det gick inte att ladda upp ordlistan: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Det gick inte att ladda ned ordlistan: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Det gick inte att ta bort molnkopian av ordlistan: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1332,5 +1332,11 @@
|
||||
"Edit Dictionary": "அகராதியைத் திருத்து",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict தொகுப்புகள் .mdx கோப்புகளைப் பயன்படுத்துகின்றன; இணைந்த .mdd மற்றும் .css கோப்புகள் விருப்பமானவை.",
|
||||
"Dictionary name": "அகராதியின் பெயர்",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "இந்த ஒலியை இங்கே இயக்க இயலவில்லை — அகராதி காலாவதியான வடிவத்தைப் பயன்படுத்துகிறது. Opus, MP3 அல்லது WAV ஒலி உள்ள ஒன்றை முயற்சிக்கவும்."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "இந்த ஒலியை இங்கே இயக்க இயலவில்லை — அகராதி காலாவதியான வடிவத்தைப் பயன்படுத்துகிறது. Opus, MP3 அல்லது WAV ஒலி உள்ள ஒன்றை முயற்சிக்கவும்.",
|
||||
"Dictionary uploaded: {{title}}": "அகராதி பதிவேற்றப்பட்டது: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "அகராதி பதிவிறக்கப்பட்டது: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "அகராதியின் கிளவுட் நகல் நீக்கப்பட்டது: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "அகராதியை பதிவேற்ற முடியவில்லை: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "அகராதியை பதிவிறக்க முடியவில்லை: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "அகராதியின் கிளவுட் நகலை நீக்க முடியவில்லை: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1308,5 +1308,11 @@
|
||||
"Edit Dictionary": "แก้ไขพจนานุกรม",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "ชุด MDict ใช้ไฟล์ .mdx ส่วนไฟล์ .mdd และ .css ที่มาด้วยกันเป็นทางเลือก",
|
||||
"Dictionary name": "ชื่อพจนานุกรม",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "ไม่สามารถเล่นเสียงนี้ที่นี่ได้ — พจนานุกรมใช้รูปแบบที่ล้าสมัย ลองใช้พจนานุกรมที่มีเสียง Opus, MP3 หรือ WAV"
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "ไม่สามารถเล่นเสียงนี้ที่นี่ได้ — พจนานุกรมใช้รูปแบบที่ล้าสมัย ลองใช้พจนานุกรมที่มีเสียง Opus, MP3 หรือ WAV",
|
||||
"Dictionary uploaded: {{title}}": "อัปโหลดพจนานุกรมแล้ว: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "ดาวน์โหลดพจนานุกรมแล้ว: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "ลบสำเนาคลาวด์ของพจนานุกรมแล้ว: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "อัปโหลดพจนานุกรมไม่สำเร็จ: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "ดาวน์โหลดพจนานุกรมไม่สำเร็จ: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "ลบสำเนาคลาวด์ของพจนานุกรมไม่สำเร็จ: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1332,5 +1332,11 @@
|
||||
"Edit Dictionary": "Sözlüğü düzenle",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict paketleri .mdx dosyalarını kullanır; eşlik eden .mdd ve .css dosyaları isteğe bağlıdır.",
|
||||
"Dictionary name": "Sözlük adı",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Bu ses burada oynatılamıyor — sözlük eski bir biçim kullanıyor. Opus, MP3 veya WAV sesi olan bir sözlük deneyin."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Bu ses burada oynatılamıyor — sözlük eski bir biçim kullanıyor. Opus, MP3 veya WAV sesi olan bir sözlük deneyin.",
|
||||
"Dictionary uploaded: {{title}}": "Sözlük yüklendi: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Sözlük indirildi: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Sözlüğün bulut kopyası silindi: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Sözlük yüklenemedi: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Sözlük indirilemedi: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Sözlüğün bulut kopyası silinemedi: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1380,5 +1380,11 @@
|
||||
"Edit Dictionary": "Редагувати словник",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Пакунки MDict використовують файли .mdx; супутні файли .mdd і .css є необов’язковими.",
|
||||
"Dictionary name": "Назва словника",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Це аудіо не можна відтворити тут — словник використовує застарілий формат. Спробуйте словник з аудіо Opus, MP3 або WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Це аудіо не можна відтворити тут — словник використовує застарілий формат. Спробуйте словник з аудіо Opus, MP3 або WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Словник завантажено: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Словник звантажено: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Копію словника у хмарі видалено: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Не вдалося завантажити словник: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Не вдалося звантажити словник: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Не вдалося видалити копію словника у хмарі: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1332,5 +1332,11 @@
|
||||
"Edit Dictionary": "Lug‘atni tahrirlash",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict to‘plamlari .mdx fayllaridan foydalanadi; hamroh .mdd va .css fayllari ixtiyoriy.",
|
||||
"Dictionary name": "Lug‘at nomi",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Bu audio bu yerda ijro etilmaydi — lug‘at eskirgan formatdan foydalanadi. Opus, MP3 yoki WAV audioli lug‘atni sinab ko‘ring."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Bu audio bu yerda ijro etilmaydi — lug‘at eskirgan formatdan foydalanadi. Opus, MP3 yoki WAV audioli lug‘atni sinab ko‘ring.",
|
||||
"Dictionary uploaded: {{title}}": "Lug'at yuklandi: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Lug'at yuklab olindi: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Lug'atning bulutdagi nusxasi o'chirildi: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Lug'atni yuklab bo'lmadi: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Lug'atni yuklab olib bo'lmadi: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Lug'atning bulutdagi nusxasini o'chirib bo'lmadi: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1308,5 +1308,11 @@
|
||||
"Edit Dictionary": "Chỉnh sửa từ điển",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Gói MDict sử dụng tệp .mdx; các tệp .mdd và .css đi kèm là tùy chọn.",
|
||||
"Dictionary name": "Tên từ điển",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Không thể phát âm thanh này tại đây — từ điển dùng định dạng đã lỗi thời. Hãy thử từ điển có âm thanh Opus, MP3 hoặc WAV."
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Không thể phát âm thanh này tại đây — từ điển dùng định dạng đã lỗi thời. Hãy thử từ điển có âm thanh Opus, MP3 hoặc WAV.",
|
||||
"Dictionary uploaded: {{title}}": "Đã tải lên từ điển: {{title}}",
|
||||
"Dictionary downloaded: {{title}}": "Đã tải xuống từ điển: {{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "Đã xoá bản sao đám mây của từ điển: {{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "Không thể tải lên từ điển: {{title}}",
|
||||
"Failed to download dictionary: {{title}}": "Không thể tải xuống từ điển: {{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "Không thể xoá bản sao đám mây của từ điển: {{title}}"
|
||||
}
|
||||
|
||||
@@ -1308,5 +1308,11 @@
|
||||
"Edit Dictionary": "编辑词典",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict 包使用 .mdx 文件;附带的 .mdd 和 .css 文件可选。",
|
||||
"Dictionary name": "词典名称",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "此处无法播放该音频 — 此词典使用过时格式。请尝试使用 Opus、MP3 或 WAV 音频的词典。"
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "此处无法播放该音频 — 此词典使用过时格式。请尝试使用 Opus、MP3 或 WAV 音频的词典。",
|
||||
"Dictionary uploaded: {{title}}": "词典已上传:{{title}}",
|
||||
"Dictionary downloaded: {{title}}": "词典已下载:{{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "已删除词典的云端副本:{{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "词典上传失败:{{title}}",
|
||||
"Failed to download dictionary: {{title}}": "词典下载失败:{{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "词典云端副本删除失败:{{title}}"
|
||||
}
|
||||
|
||||
@@ -1308,5 +1308,11 @@
|
||||
"Edit Dictionary": "編輯辭典",
|
||||
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict 套件使用 .mdx 檔案;附帶的 .mdd 與 .css 檔案為選用。",
|
||||
"Dictionary name": "辭典名稱",
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "無法在此播放此音訊 — 此辭典使用過時格式。請改用提供 Opus、MP3 或 WAV 音訊的辭典。"
|
||||
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "無法在此播放此音訊 — 此辭典使用過時格式。請改用提供 Opus、MP3 或 WAV 音訊的辭典。",
|
||||
"Dictionary uploaded: {{title}}": "詞典已上傳:{{title}}",
|
||||
"Dictionary downloaded: {{title}}": "詞典已下載:{{title}}",
|
||||
"Deleted cloud copy of the dictionary: {{title}}": "已刪除詞典的雲端副本:{{title}}",
|
||||
"Failed to upload dictionary: {{title}}": "詞典上傳失敗:{{title}}",
|
||||
"Failed to download dictionary: {{title}}": "詞典下載失敗:{{title}}",
|
||||
"Failed to delete cloud copy of the dictionary: {{title}}": "詞典雲端副本刪除失敗:{{title}}"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ vi.mock('@/services/environment', async () => {
|
||||
|
||||
const mockAppService = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
// EnvProvider's mount effect calls appService.loadSettings() to seed
|
||||
// replica sync. Returning a settings object without replicaDeviceId
|
||||
// makes init early-exit cleanly (no warn, no real network).
|
||||
loadSettings: vi.fn().mockResolvedValue({}),
|
||||
// Add any other methods from AppService interface
|
||||
};
|
||||
|
||||
|
||||
@@ -61,7 +61,13 @@ vi.mock('@/services/environment', async (importOriginal) => {
|
||||
: {}), // keep all real default fields
|
||||
API_BASE: 'http://localhost',
|
||||
ENABLE_TRANSLATOR: false,
|
||||
getAppService: vi.fn().mockResolvedValue(null),
|
||||
// EnvProvider's mount effect calls appService.loadSettings() to seed
|
||||
// replica sync. Stubbing with loadSettings returning {} (no
|
||||
// replicaDeviceId) makes init early-exit cleanly. Returning null
|
||||
// would crash on `service.loadSettings()` and spam stderr.
|
||||
getAppService: vi.fn().mockResolvedValue({
|
||||
loadSettings: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
|
||||
const pullSpy = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const getReplicaSyncSpy = vi.fn();
|
||||
let envValue: { envConfig: unknown; appService: unknown } = {
|
||||
envConfig: { name: 'env' },
|
||||
appService: null,
|
||||
};
|
||||
|
||||
vi.mock('@/services/sync/replicaPullDictionaries', () => ({
|
||||
pullDictionariesAndApply: (...args: unknown[]) => pullSpy(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/sync/replicaSync', () => ({
|
||||
getReplicaSync: () => getReplicaSyncSpy(),
|
||||
}));
|
||||
|
||||
vi.mock('@/context/EnvContext', () => ({
|
||||
useEnv: () => envValue,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/transferManager', () => ({
|
||||
transferManager: { queueReplicaDownload: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/store/customDictionaryStore', () => ({
|
||||
useCustomDictionaryStore: {
|
||||
getState: () => ({
|
||||
applyRemoteDictionary: vi.fn(),
|
||||
softDeleteByContentId: vi.fn(),
|
||||
loadCustomDictionaries: vi.fn(async () => {}),
|
||||
}),
|
||||
},
|
||||
findDictionaryByContentId: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/access', () => ({
|
||||
getAccessToken: async () => 'token',
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/misc', () => ({
|
||||
uniqueId: () => 'fresh-bundle',
|
||||
}));
|
||||
|
||||
import { useReplicaPull, __resetReplicaPullForTests } from '@/hooks/useReplicaPull';
|
||||
|
||||
const fakeService = { createDir: vi.fn(), name: 'fake' };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
pullSpy.mockClear();
|
||||
pullSpy.mockResolvedValue(undefined);
|
||||
getReplicaSyncSpy.mockReset();
|
||||
__resetReplicaPullForTests();
|
||||
envValue = { envConfig: { name: 'env' }, appService: fakeService };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('useReplicaPull', () => {
|
||||
test('does not pull before delayMs elapses', () => {
|
||||
getReplicaSyncSpy.mockReturnValue({ manager: {} });
|
||||
renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 5_000 }));
|
||||
|
||||
vi.advanceTimersByTime(4_999);
|
||||
expect(pullSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('fires pull after delayMs', async () => {
|
||||
getReplicaSyncSpy.mockReturnValue({ manager: {} });
|
||||
renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 1_000 }));
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_001);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pullSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('skips when appService is null', () => {
|
||||
envValue = { envConfig: { name: 'env' }, appService: null };
|
||||
getReplicaSyncSpy.mockReturnValue({ manager: {} });
|
||||
renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 }));
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(pullSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips when replica sync context is not initialized', () => {
|
||||
getReplicaSyncSpy.mockReturnValue(null);
|
||||
renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 }));
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(pullSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('only pulls once per kind across multiple mounts', async () => {
|
||||
getReplicaSyncSpy.mockReturnValue({ manager: {} });
|
||||
const first = renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pullSpy).toHaveBeenCalledOnce();
|
||||
first.unmount();
|
||||
|
||||
// Second mount (e.g., navigating to the reader) — same kind should NOT
|
||||
// re-pull. ReplicaSyncManager.startAutoSync handles visibility / online
|
||||
// resync; this hook is for the once-per-session initial pull only.
|
||||
renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pullSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('failed pull releases the dedup slot so a later navigation can retry', async () => {
|
||||
getReplicaSyncSpy.mockReturnValue({ manager: {} });
|
||||
pullSpy.mockRejectedValueOnce(new Error('flaky'));
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const first = renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pullSpy).toHaveBeenCalledTimes(1);
|
||||
first.unmount();
|
||||
|
||||
// The slot was released after the rejection — second mount triggers
|
||||
// a fresh attempt.
|
||||
renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pullSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('cleanup cancels a pending pull when the component unmounts before delayMs', () => {
|
||||
getReplicaSyncSpy.mockReturnValue({ manager: {} });
|
||||
const view = renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 5_000 }));
|
||||
vi.advanceTimersByTime(2_000);
|
||||
view.unmount();
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(pullSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -230,6 +230,46 @@ describe('removeReplica + mergeReplica (tombstones)', () => {
|
||||
expect(merged.updated_at_ts).toBe(hlc(300));
|
||||
});
|
||||
|
||||
test('manifest-only merge advances updated_at_ts so pull cursors see it', () => {
|
||||
const metadata = emptyRow({
|
||||
fields_jsonb: setField({}, 'name', 'Foo', hlc(100), DEV_A),
|
||||
manifest_jsonb: null,
|
||||
updated_at_ts: hlc(100),
|
||||
});
|
||||
const manifest = emptyRow({
|
||||
fields_jsonb: {},
|
||||
manifest_jsonb: {
|
||||
schemaVersion: 1,
|
||||
files: [{ filename: 'foo.mdx', byteSize: 1000, partialMd5: 'a'.repeat(32) }],
|
||||
},
|
||||
updated_at_ts: hlc(200),
|
||||
});
|
||||
const merged = mergeReplica(metadata, manifest);
|
||||
expect(merged.fields_jsonb['name']?.v).toBe('Foo');
|
||||
expect(merged.manifest_jsonb?.files).toHaveLength(1);
|
||||
expect(merged.updated_at_ts).toBe(hlc(200));
|
||||
});
|
||||
|
||||
test('metadata-only merge does not clear an existing manifest', () => {
|
||||
const withManifest = emptyRow({
|
||||
fields_jsonb: setField({}, 'name', 'Foo', hlc(100), DEV_A),
|
||||
manifest_jsonb: {
|
||||
schemaVersion: 1,
|
||||
files: [{ filename: 'foo.mdx', byteSize: 1000, partialMd5: 'a'.repeat(32) }],
|
||||
},
|
||||
updated_at_ts: hlc(200),
|
||||
});
|
||||
const metadataOnly = emptyRow({
|
||||
fields_jsonb: setField({}, 'name', 'Renamed', hlc(300), DEV_A),
|
||||
manifest_jsonb: null,
|
||||
updated_at_ts: hlc(300),
|
||||
});
|
||||
const merged = mergeReplica(withManifest, metadataOnly);
|
||||
expect(merged.fields_jsonb['name']?.v).toBe('Renamed');
|
||||
expect(merged.manifest_jsonb?.files).toHaveLength(1);
|
||||
expect(merged.updated_at_ts).toBe(hlc(300));
|
||||
});
|
||||
|
||||
test('two tombstones: keep the larger HLC', () => {
|
||||
const a = emptyRow({ deleted_at_ts: hlc(100), updated_at_ts: hlc(100) });
|
||||
const b = emptyRow({ deleted_at_ts: hlc(200), updated_at_ts: hlc(200) });
|
||||
@@ -264,4 +304,52 @@ describe('mergeReplica reincarnation interactions', () => {
|
||||
const b = emptyRow({ reincarnation: 'epoch-2', deleted_at_ts: null, updated_at_ts: hlc(200) });
|
||||
expect(mergeReplica(a, b).reincarnation).toBe('epoch-2');
|
||||
});
|
||||
|
||||
test('metadata-only row with null reincarnation does not clear an existing token', () => {
|
||||
const revived = emptyRow({
|
||||
reincarnation: 'epoch-1',
|
||||
deleted_at_ts: hlc(100),
|
||||
updated_at_ts: hlc(200),
|
||||
});
|
||||
const rename = emptyRow({
|
||||
fields_jsonb: setField({}, 'name', 'Renamed', hlc(300), DEV_A),
|
||||
reincarnation: null,
|
||||
deleted_at_ts: null,
|
||||
updated_at_ts: hlc(300),
|
||||
});
|
||||
const merged = mergeReplica(revived, rename);
|
||||
expect(merged.fields_jsonb['name']?.v).toBe('Renamed');
|
||||
expect(merged.reincarnation).toBe('epoch-1');
|
||||
expect(mergeReplica(rename, revived).reincarnation).toBe('epoch-1');
|
||||
});
|
||||
|
||||
test('newer tombstone clears an existing reincarnation token', () => {
|
||||
const revived = emptyRow({
|
||||
reincarnation: 'epoch-1',
|
||||
deleted_at_ts: hlc(100),
|
||||
updated_at_ts: hlc(200),
|
||||
});
|
||||
const deleted = emptyRow({
|
||||
reincarnation: null,
|
||||
deleted_at_ts: hlc(300),
|
||||
updated_at_ts: hlc(300),
|
||||
});
|
||||
const merged = mergeReplica(revived, deleted);
|
||||
expect(merged.deleted_at_ts).toBe(hlc(300));
|
||||
expect(merged.reincarnation).toBe(null);
|
||||
});
|
||||
|
||||
test('older duplicate tombstone does not clear a later reincarnation token', () => {
|
||||
const revived = emptyRow({
|
||||
reincarnation: 'epoch-1',
|
||||
deleted_at_ts: hlc(100),
|
||||
updated_at_ts: hlc(200),
|
||||
});
|
||||
const duplicateDelete = emptyRow({
|
||||
reincarnation: null,
|
||||
deleted_at_ts: hlc(100),
|
||||
updated_at_ts: hlc(100),
|
||||
});
|
||||
expect(mergeReplica(revived, duplicateDelete).reincarnation).toBe('epoch-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { isReplicaRowAlive } from '@/libs/replicaInterpret';
|
||||
import { hlcPack } from '@/libs/crdt';
|
||||
import type { Hlc, ReplicaRow } from '@/types/replica';
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
const DEV = 'dev-a';
|
||||
|
||||
const baseRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
|
||||
user_id: 'u1',
|
||||
kind: 'dictionary',
|
||||
replica_id: 'content-hash-abc',
|
||||
fields_jsonb: {},
|
||||
manifest_jsonb: null,
|
||||
deleted_at_ts: null,
|
||||
reincarnation: null,
|
||||
updated_at_ts: hlcPack(NOW, 0, DEV) as Hlc,
|
||||
schema_version: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('isReplicaRowAlive', () => {
|
||||
test('alive when no tombstone and no reincarnation (fresh row)', () => {
|
||||
expect(isReplicaRowAlive(baseRow())).toBe(true);
|
||||
});
|
||||
|
||||
test('alive when no tombstone (just deleted_at_ts is null)', () => {
|
||||
expect(isReplicaRowAlive(baseRow({ reincarnation: 'epoch-1' }))).toBe(true);
|
||||
});
|
||||
|
||||
test('dead when tombstoned and no reincarnation token (the user just deleted it)', () => {
|
||||
const tombstone = hlcPack(NOW, 0, DEV) as Hlc;
|
||||
expect(
|
||||
isReplicaRowAlive(
|
||||
baseRow({
|
||||
deleted_at_ts: tombstone,
|
||||
updated_at_ts: tombstone,
|
||||
reincarnation: null,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('alive when reincarnation token is newer than the tombstone', () => {
|
||||
expect(
|
||||
isReplicaRowAlive(
|
||||
baseRow({
|
||||
deleted_at_ts: hlcPack(NOW, 0, DEV) as Hlc,
|
||||
reincarnation: 'epoch-1',
|
||||
updated_at_ts: hlcPack(NOW + 1000, 0, DEV) as Hlc,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('dead when tombstone is newer than the reincarnation (deleted again after revival)', () => {
|
||||
expect(
|
||||
isReplicaRowAlive(
|
||||
baseRow({
|
||||
deleted_at_ts: hlcPack(NOW + 5000, 0, DEV) as Hlc,
|
||||
reincarnation: 'epoch-1',
|
||||
updated_at_ts: hlcPack(NOW + 1000, 0, DEV) as Hlc,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('alive when reincarnation == tombstone HLC (edge of order)', () => {
|
||||
const t = hlcPack(NOW + 1000, 0, DEV) as Hlc;
|
||||
expect(
|
||||
isReplicaRowAlive(
|
||||
baseRow({
|
||||
deleted_at_ts: t,
|
||||
reincarnation: 'epoch-1',
|
||||
updated_at_ts: t,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('dead when reincarnation is set but tombstone is null is impossible — but tolerate gracefully', () => {
|
||||
expect(isReplicaRowAlive(baseRow({ deleted_at_ts: null, reincarnation: 'epoch-1' }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
findExistingDictionaryMatches,
|
||||
findTombstonedDictionaryMatches,
|
||||
preserveLiveDictionaryState,
|
||||
preserveUserCustomName,
|
||||
shouldMintReincarnationForLiveReimport,
|
||||
} from '@/services/dictionaries/dictionaryDedup';
|
||||
import type { ImportedDictionary } from '@/services/dictionaries/types';
|
||||
|
||||
const baseDict = (overrides: Partial<ImportedDictionary> = {}): ImportedDictionary => ({
|
||||
id: 'bundle-1',
|
||||
contentId: 'content-hash-A',
|
||||
kind: 'mdict',
|
||||
name: 'Webster Original',
|
||||
bundleDir: 'bundle-1',
|
||||
files: { mdx: 'webster.mdx' },
|
||||
addedAt: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('findExistingDictionaryMatches', () => {
|
||||
test('matches by contentId when both incoming and existing have it', () => {
|
||||
const existing = [baseDict({ id: 'old', name: 'Webster Original' })];
|
||||
const incoming = baseDict({ id: 'new', name: 'Webster Original' });
|
||||
expect(findExistingDictionaryMatches(incoming, existing)).toEqual(existing);
|
||||
});
|
||||
|
||||
test('matches by contentId even when the user has renamed the existing entry', () => {
|
||||
// Bug repro: user renamed the dict. Re-importing the same file produces
|
||||
// a fresh dict whose .name matches the bundle's parsed Title (the
|
||||
// ORIGINAL name), not the user's new label. ContentId-based match catches
|
||||
// it; name-based match would not.
|
||||
const renamed = baseDict({
|
||||
id: 'old',
|
||||
contentId: 'content-hash-A',
|
||||
name: 'My Renamed Dict',
|
||||
});
|
||||
const incoming = baseDict({
|
||||
id: 'new',
|
||||
contentId: 'content-hash-A',
|
||||
name: 'Webster Original',
|
||||
});
|
||||
expect(findExistingDictionaryMatches(incoming, [renamed])).toEqual([renamed]);
|
||||
});
|
||||
|
||||
test('contentId mismatch with name match: still matches by name (legacy fallback)', () => {
|
||||
// Legacy bundle: existing dict has no contentId; incoming has one.
|
||||
// The legacy entry's name is the only signal we have to dedup.
|
||||
const legacy = baseDict({
|
||||
id: 'old',
|
||||
contentId: undefined,
|
||||
name: 'Webster Original',
|
||||
});
|
||||
const incoming = baseDict({
|
||||
id: 'new',
|
||||
contentId: 'content-hash-A',
|
||||
name: 'Webster Original',
|
||||
});
|
||||
expect(findExistingDictionaryMatches(incoming, [legacy])).toEqual([legacy]);
|
||||
});
|
||||
|
||||
test('different contentId + same name does NOT match (different file with same Title)', () => {
|
||||
const existing = baseDict({ id: 'old', contentId: 'content-hash-A', name: 'Webster' });
|
||||
const incoming = baseDict({ id: 'new', contentId: 'content-hash-B', name: 'Webster' });
|
||||
expect(findExistingDictionaryMatches(incoming, [existing])).toEqual([]);
|
||||
});
|
||||
|
||||
test('soft-deleted entries are not considered matches', () => {
|
||||
const deleted = baseDict({ id: 'old', deletedAt: Date.now() });
|
||||
const incoming = baseDict({ id: 'new' });
|
||||
expect(findExistingDictionaryMatches(incoming, [deleted])).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns all existing entries with the same contentId (multi-import history)', () => {
|
||||
const a = baseDict({ id: 'old-1', contentId: 'content-hash-A' });
|
||||
const b = baseDict({ id: 'old-2', contentId: 'content-hash-A' });
|
||||
const incoming = baseDict({ id: 'new', contentId: 'content-hash-A' });
|
||||
expect(findExistingDictionaryMatches(incoming, [a, b])).toEqual([a, b]);
|
||||
});
|
||||
|
||||
test('returns all existing entries with the same name (multi-import legacy)', () => {
|
||||
const a = baseDict({ id: 'old-1', contentId: undefined, name: 'Webster' });
|
||||
const b = baseDict({ id: 'old-2', contentId: undefined, name: 'Webster' });
|
||||
const incoming = baseDict({ id: 'new', contentId: 'content-hash-A', name: 'Webster' });
|
||||
expect(findExistingDictionaryMatches(incoming, [a, b])).toEqual([a, b]);
|
||||
});
|
||||
|
||||
test('incoming without contentId only matches existing entries that also lack contentId', () => {
|
||||
// Asymmetry: an existing entry with contentId is "tier-1 identity" — we
|
||||
// know its content hash. An incoming bundle without contentId is "tier-0".
|
||||
// Matching across tiers risks false positives (different file with same
|
||||
// Title), so we match only within the legacy/legacy bucket. New imports
|
||||
// always carry contentId so this only fires for synthetic call-sites.
|
||||
const tier1 = baseDict({ id: 'old-tier1', contentId: 'A', name: 'Webster' });
|
||||
const tier0 = baseDict({ id: 'old-tier0', contentId: undefined, name: 'Webster' });
|
||||
const incoming = baseDict({ id: 'new', contentId: undefined, name: 'Webster' });
|
||||
expect(findExistingDictionaryMatches(incoming, [tier1, tier0])).toEqual([tier0]);
|
||||
});
|
||||
|
||||
test('no matches when nothing aligns', () => {
|
||||
const existing = baseDict({ id: 'old', contentId: 'A', name: 'Foo' });
|
||||
const incoming = baseDict({ id: 'new', contentId: 'B', name: 'Bar' });
|
||||
expect(findExistingDictionaryMatches(incoming, [existing])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preserveUserCustomName', () => {
|
||||
test("returns the new dict with the existing entry's name", () => {
|
||||
const existing = baseDict({ id: 'old', name: 'My Renamed Dict' });
|
||||
const incoming = baseDict({ id: 'new', name: 'Webster Original' });
|
||||
expect(preserveUserCustomName(incoming, [existing]).name).toBe('My Renamed Dict');
|
||||
});
|
||||
|
||||
test("uses the FIRST matched entry's name when multiple exist", () => {
|
||||
const a = baseDict({ id: 'old-1', name: "Alice's label" });
|
||||
const b = baseDict({ id: 'old-2', name: "Bob's label" });
|
||||
const incoming = baseDict({ id: 'new', name: 'Original' });
|
||||
expect(preserveUserCustomName(incoming, [a, b]).name).toBe("Alice's label");
|
||||
});
|
||||
|
||||
test('returns the new dict unchanged when matches array is empty', () => {
|
||||
const incoming = baseDict({ id: 'new', name: 'Original' });
|
||||
expect(preserveUserCustomName(incoming, []).name).toBe('Original');
|
||||
});
|
||||
|
||||
test('returns a new object (does not mutate the incoming dict)', () => {
|
||||
const existing = baseDict({ name: 'Renamed' });
|
||||
const incoming = baseDict({ name: 'Original' });
|
||||
const result = preserveUserCustomName(incoming, [existing]);
|
||||
expect(result).not.toBe(incoming);
|
||||
expect(incoming.name).toBe('Original');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTombstonedDictionaryMatches', () => {
|
||||
test('matches soft-deleted entries with the same contentId', () => {
|
||||
const tombstoned = baseDict({
|
||||
id: 'old',
|
||||
contentId: 'content-hash-A',
|
||||
deletedAt: 1700000000000,
|
||||
});
|
||||
const incoming = baseDict({ id: 'new', contentId: 'content-hash-A' });
|
||||
expect(findTombstonedDictionaryMatches(incoming, [tombstoned])).toEqual([tombstoned]);
|
||||
});
|
||||
|
||||
test('does NOT match live entries (those go through the live-replacement path)', () => {
|
||||
const live = baseDict({ id: 'old', contentId: 'A', deletedAt: undefined });
|
||||
const incoming = baseDict({ id: 'new', contentId: 'A' });
|
||||
expect(findTombstonedDictionaryMatches(incoming, [live])).toEqual([]);
|
||||
});
|
||||
|
||||
test('does NOT match when contentIds differ', () => {
|
||||
const tombstoned = baseDict({ id: 'old', contentId: 'A', deletedAt: 1 });
|
||||
const incoming = baseDict({ id: 'new', contentId: 'B' });
|
||||
expect(findTombstonedDictionaryMatches(incoming, [tombstoned])).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns [] when incoming has no contentId (legacy bundles cannot reincarnate)', () => {
|
||||
const tombstoned = baseDict({ id: 'old', contentId: 'A', deletedAt: 1 });
|
||||
const incoming = baseDict({ id: 'new', contentId: undefined });
|
||||
expect(findTombstonedDictionaryMatches(incoming, [tombstoned])).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns all tombstoned entries with the same contentId (multi-history)', () => {
|
||||
const a = baseDict({ id: 'old-1', contentId: 'A', deletedAt: 1 });
|
||||
const b = baseDict({ id: 'old-2', contentId: 'A', deletedAt: 2 });
|
||||
const incoming = baseDict({ id: 'new', contentId: 'A' });
|
||||
expect(findTombstonedDictionaryMatches(incoming, [a, b])).toEqual([a, b]);
|
||||
});
|
||||
|
||||
test('does NOT match tombstoned entries that lack contentId (legacy)', () => {
|
||||
const tombstoned = baseDict({ id: 'old', contentId: undefined, deletedAt: 1 });
|
||||
const incoming = baseDict({ id: 'new', contentId: 'A' });
|
||||
expect(findTombstonedDictionaryMatches(incoming, [tombstoned])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preserveLiveDictionaryState', () => {
|
||||
test('carries the existing entry name, addedAt, and reincarnation token onto incoming', () => {
|
||||
const existing = baseDict({
|
||||
id: 'old',
|
||||
name: 'User Label',
|
||||
addedAt: 123,
|
||||
reincarnation: 'epoch-1',
|
||||
});
|
||||
const incoming = baseDict({
|
||||
id: 'new',
|
||||
name: 'Parsed Label',
|
||||
addedAt: 456,
|
||||
reincarnation: undefined,
|
||||
});
|
||||
const result = preserveLiveDictionaryState(incoming, [existing]);
|
||||
expect(result.name).toBe('User Label');
|
||||
expect(result.addedAt).toBe(123);
|
||||
expect(result.reincarnation).toBe('epoch-1');
|
||||
});
|
||||
|
||||
test('keeps file-backed fields from the incoming bundle', () => {
|
||||
const existing = baseDict({
|
||||
id: 'old',
|
||||
contentId: 'old-content',
|
||||
bundleDir: 'old-dir',
|
||||
files: { mdx: 'old.mdx' },
|
||||
lang: 'en',
|
||||
unsupported: true,
|
||||
unsupportedReason: 'old parser failure',
|
||||
unavailable: true,
|
||||
});
|
||||
const incoming = baseDict({
|
||||
id: 'new',
|
||||
contentId: 'new-content',
|
||||
bundleDir: 'new-dir',
|
||||
files: { mdx: 'new.mdx' },
|
||||
lang: 'gbk',
|
||||
unsupported: undefined,
|
||||
unsupportedReason: undefined,
|
||||
unavailable: undefined,
|
||||
});
|
||||
const result = preserveLiveDictionaryState(incoming, [existing]);
|
||||
expect(result.id).toBe('new');
|
||||
expect(result.contentId).toBe('new-content');
|
||||
expect(result.bundleDir).toBe('new-dir');
|
||||
expect(result.files).toEqual({ mdx: 'new.mdx' });
|
||||
expect(result.lang).toBe('gbk');
|
||||
expect(result.unsupported).toBeUndefined();
|
||||
expect(result.unsupportedReason).toBeUndefined();
|
||||
expect(result.unavailable).toBeUndefined();
|
||||
});
|
||||
|
||||
test("uses the FIRST matched entry's live state when multiple exist", () => {
|
||||
const a = baseDict({ id: 'old-1', name: 'A', addedAt: 1, reincarnation: 'epoch-A' });
|
||||
const b = baseDict({ id: 'old-2', name: 'B', addedAt: 2, reincarnation: 'epoch-B' });
|
||||
const incoming = baseDict({ id: 'new', name: 'Parsed', addedAt: 3 });
|
||||
const result = preserveLiveDictionaryState(incoming, [a, b]);
|
||||
expect(result.name).toBe('A');
|
||||
expect(result.addedAt).toBe(1);
|
||||
expect(result.reincarnation).toBe('epoch-A');
|
||||
});
|
||||
|
||||
test('no-op when matches is empty', () => {
|
||||
const incoming = baseDict({ id: 'new', name: 'Parsed', addedAt: 3 });
|
||||
expect(preserveLiveDictionaryState(incoming, [])).toEqual(incoming);
|
||||
});
|
||||
|
||||
test('does NOT mutate incoming', () => {
|
||||
const existing = baseDict({ id: 'old', name: 'User Label', reincarnation: 'epoch-1' });
|
||||
const incoming = baseDict({ id: 'new', name: 'Parsed' });
|
||||
const result = preserveLiveDictionaryState(incoming, [existing]);
|
||||
expect(result).not.toBe(incoming);
|
||||
expect(incoming.name).toBe('Parsed');
|
||||
expect(incoming.reincarnation).toBeUndefined();
|
||||
});
|
||||
|
||||
test('preserves an explicit incoming reincarnation when matches has none', () => {
|
||||
const existing = baseDict({ id: 'old', reincarnation: undefined });
|
||||
const incoming = baseDict({ id: 'new', reincarnation: 'fresh-mint' });
|
||||
expect(preserveLiveDictionaryState(incoming, [existing]).reincarnation).toBe('fresh-mint');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldMintReincarnationForLiveReimport', () => {
|
||||
test('mints when explicit live re-import matches the same content and has no token', () => {
|
||||
const existing = baseDict({ id: 'old', contentId: 'content-hash-A' });
|
||||
const incoming = baseDict({ id: 'new', contentId: 'content-hash-A' });
|
||||
expect(shouldMintReincarnationForLiveReimport(incoming, [existing])).toBe(true);
|
||||
});
|
||||
|
||||
test('does not mint when the live entry already has a token to preserve', () => {
|
||||
const existing = baseDict({
|
||||
id: 'old',
|
||||
contentId: 'content-hash-A',
|
||||
reincarnation: 'epoch-1',
|
||||
});
|
||||
const incoming = baseDict({ id: 'new', contentId: 'content-hash-A' });
|
||||
expect(shouldMintReincarnationForLiveReimport(incoming, [existing])).toBe(false);
|
||||
});
|
||||
|
||||
test('does not mint for legacy name-only replacements', () => {
|
||||
const existing = baseDict({ id: 'old', contentId: undefined, name: 'Webster' });
|
||||
const incoming = baseDict({ id: 'new', contentId: 'content-hash-A', name: 'Webster' });
|
||||
expect(shouldMintReincarnationForLiveReimport(incoming, [existing])).toBe(false);
|
||||
});
|
||||
|
||||
test('does not mint without matches or without incoming contentId', () => {
|
||||
expect(shouldMintReincarnationForLiveReimport(baseDict(), [])).toBe(false);
|
||||
expect(
|
||||
shouldMintReincarnationForLiveReimport(baseDict({ contentId: undefined }), [baseDict()]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/services/transferManager', () => ({
|
||||
transferManager: {
|
||||
isReady: vi.fn(),
|
||||
queueReplicaUpload: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { transferManager } from '@/services/transferManager';
|
||||
import { queueDictionaryBinaryUpload } from '@/services/sync/replicaBinaryUpload';
|
||||
import type { ImportedDictionary } from '@/services/dictionaries/types';
|
||||
import type { AppService } from '@/types/system';
|
||||
|
||||
const mockIsReady = transferManager.isReady as ReturnType<typeof vi.fn>;
|
||||
const mockQueueReplicaUpload = transferManager.queueReplicaUpload as ReturnType<typeof vi.fn>;
|
||||
|
||||
const makeFakeAppService = (sizes: Record<string, number>) => ({
|
||||
openFile: vi.fn(async (path: string) => ({
|
||||
size: sizes[path] ?? 0,
|
||||
name: path,
|
||||
})),
|
||||
});
|
||||
|
||||
const baseDict = (overrides: Partial<ImportedDictionary> = {}): ImportedDictionary => ({
|
||||
id: 'bundle-id',
|
||||
contentId: 'content-hash-abc',
|
||||
kind: 'mdict',
|
||||
name: 'Webster',
|
||||
bundleDir: 'bundle-dir',
|
||||
files: { mdx: 'webster.mdx', mdd: ['webster.mdd'] },
|
||||
addedAt: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('queueDictionaryBinaryUpload', () => {
|
||||
test('no-ops when contentId is missing (legacy bundle)', async () => {
|
||||
mockIsReady.mockReturnValue(true);
|
||||
const fakeAppService = makeFakeAppService({}) as unknown as AppService;
|
||||
const result = await queueDictionaryBinaryUpload(
|
||||
baseDict({ contentId: undefined }),
|
||||
fakeAppService,
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
expect(mockQueueReplicaUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('no-ops when TransferManager is not initialized', async () => {
|
||||
mockIsReady.mockReturnValue(false);
|
||||
const fakeAppService = makeFakeAppService({}) as unknown as AppService;
|
||||
const result = await queueDictionaryBinaryUpload(baseDict(), fakeAppService);
|
||||
expect(result).toBe(null);
|
||||
expect(mockQueueReplicaUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('queues upload with file sizes resolved via fs', async () => {
|
||||
mockIsReady.mockReturnValue(true);
|
||||
mockQueueReplicaUpload.mockReturnValue('transfer-id-1');
|
||||
const fakeAppService = makeFakeAppService({
|
||||
'bundle-dir/webster.mdx': 1_000_000,
|
||||
'bundle-dir/webster.mdd': 5_000_000,
|
||||
}) as unknown as AppService;
|
||||
|
||||
const result = await queueDictionaryBinaryUpload(baseDict(), fakeAppService);
|
||||
|
||||
expect(result).toBe('transfer-id-1');
|
||||
expect(mockQueueReplicaUpload).toHaveBeenCalledOnce();
|
||||
const [kind, contentId, displayTitle, files, base, opts] =
|
||||
mockQueueReplicaUpload.mock.calls[0]!;
|
||||
expect(kind).toBe('dictionary');
|
||||
expect(contentId).toBe('content-hash-abc');
|
||||
expect(displayTitle).toBe('Webster');
|
||||
expect(files).toEqual([
|
||||
{ logical: 'webster.mdx', lfp: 'bundle-dir/webster.mdx', byteSize: 1_000_000 },
|
||||
{ logical: 'webster.mdd', lfp: 'bundle-dir/webster.mdd', byteSize: 5_000_000 },
|
||||
]);
|
||||
expect(base).toBe('Dictionaries');
|
||||
expect(opts).toEqual({ reincarnation: undefined });
|
||||
});
|
||||
|
||||
test('passes reincarnation token through to the replica transfer', async () => {
|
||||
mockIsReady.mockReturnValue(true);
|
||||
mockQueueReplicaUpload.mockReturnValue('transfer-id-1');
|
||||
const fakeAppService = makeFakeAppService({
|
||||
'bundle-dir/webster.mdx': 1_000_000,
|
||||
'bundle-dir/webster.mdd': 5_000_000,
|
||||
}) as unknown as AppService;
|
||||
|
||||
await queueDictionaryBinaryUpload(baseDict({ reincarnation: 'epoch-1' }), fakeAppService);
|
||||
|
||||
expect(mockQueueReplicaUpload.mock.calls[0]![5]).toEqual({ reincarnation: 'epoch-1' });
|
||||
});
|
||||
|
||||
test('returns null when bundle has no enumerable files', async () => {
|
||||
mockIsReady.mockReturnValue(true);
|
||||
const fakeAppService = makeFakeAppService({}) as unknown as AppService;
|
||||
const result = await queueDictionaryBinaryUpload(
|
||||
baseDict({ kind: 'mdict', files: {} }),
|
||||
fakeAppService,
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
expect(mockQueueReplicaUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles stardict bundle with all four files', async () => {
|
||||
mockIsReady.mockReturnValue(true);
|
||||
mockQueueReplicaUpload.mockReturnValue('t-2');
|
||||
const dict = baseDict({
|
||||
kind: 'stardict',
|
||||
files: {
|
||||
ifo: 'cmu.ifo',
|
||||
idx: 'cmu.idx',
|
||||
dict: 'cmu.dict.dz',
|
||||
syn: 'cmu.syn',
|
||||
idxOffsets: 'cmu.idx.offsets',
|
||||
},
|
||||
});
|
||||
const fakeAppService = makeFakeAppService({
|
||||
'bundle-dir/cmu.ifo': 100,
|
||||
'bundle-dir/cmu.idx': 200,
|
||||
'bundle-dir/cmu.dict.dz': 300,
|
||||
'bundle-dir/cmu.syn': 400,
|
||||
}) as unknown as AppService;
|
||||
|
||||
await queueDictionaryBinaryUpload(dict, fakeAppService);
|
||||
|
||||
const files = mockQueueReplicaUpload.mock.calls[0]![3];
|
||||
expect(files.map((f: { logical: string }) => f.logical)).toEqual([
|
||||
'cmu.ifo',
|
||||
'cmu.idx',
|
||||
'cmu.dict.dz',
|
||||
'cmu.syn',
|
||||
]);
|
||||
});
|
||||
|
||||
test('closes opened files (matches getBookFileSize pattern)', async () => {
|
||||
mockIsReady.mockReturnValue(true);
|
||||
mockQueueReplicaUpload.mockReturnValue('t-3');
|
||||
const close = vi.fn();
|
||||
const fakeAppService = {
|
||||
openFile: vi.fn(async () => ({ size: 100, close })),
|
||||
};
|
||||
await queueDictionaryBinaryUpload(baseDict(), fakeAppService as unknown as AppService);
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { createSettingsCursorStore } from '@/services/sync/replicaCursorStore';
|
||||
import type { AppService } from '@/types/system';
|
||||
import type { Hlc } from '@/types/replica';
|
||||
import type { SystemSettings } from '@/types/settings';
|
||||
|
||||
const makeFakeAppService = (initial: Partial<SystemSettings> = {}) => {
|
||||
let settings = { ...initial } as SystemSettings;
|
||||
return {
|
||||
loadSettings: vi.fn(async () => settings),
|
||||
saveSettings: vi.fn(async (s: SystemSettings) => {
|
||||
settings = { ...s };
|
||||
}),
|
||||
getSettings: () => settings,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('createSettingsCursorStore', () => {
|
||||
test('hydrates cache from settings.lastSyncedAtReplicas on init', async () => {
|
||||
const fake = makeFakeAppService({
|
||||
lastSyncedAtReplicas: { dictionary: 'cur-dict', font: 'cur-font' },
|
||||
});
|
||||
const store = createSettingsCursorStore(fake as unknown as AppService);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(store.get('dictionary')).toBe('cur-dict');
|
||||
expect(store.get('font')).toBe('cur-font');
|
||||
});
|
||||
|
||||
test('get returns null when cursor not in settings', async () => {
|
||||
const fake = makeFakeAppService();
|
||||
const store = createSettingsCursorStore(fake as unknown as AppService);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(store.get('dictionary')).toBe(null);
|
||||
});
|
||||
|
||||
test('set updates the cache synchronously', () => {
|
||||
const fake = makeFakeAppService();
|
||||
const store = createSettingsCursorStore(fake as unknown as AppService);
|
||||
store.set('dictionary', 'cur-1' as Hlc);
|
||||
expect(store.get('dictionary')).toBe('cur-1');
|
||||
});
|
||||
|
||||
test('set debounces a save flush; no save fires immediately', () => {
|
||||
const fake = makeFakeAppService({ replicaDeviceId: 'dev-a' });
|
||||
const store = createSettingsCursorStore(fake as unknown as AppService, { debounceMs: 1000 });
|
||||
store.set('dictionary', 'cur-1' as Hlc);
|
||||
expect(fake.saveSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('save fires after debounceMs', async () => {
|
||||
const fake = makeFakeAppService({ replicaDeviceId: 'dev-a' });
|
||||
const store = createSettingsCursorStore(fake as unknown as AppService, { debounceMs: 1000 });
|
||||
store.set('dictionary', 'cur-1' as Hlc);
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(fake.saveSettings).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(2);
|
||||
expect(fake.saveSettings).toHaveBeenCalledOnce();
|
||||
const saved = fake.saveSettings.mock.calls[0]![0];
|
||||
expect(saved.lastSyncedAtReplicas).toEqual({ dictionary: 'cur-1' });
|
||||
});
|
||||
|
||||
test('successive sets within debounce window collapse to one save', async () => {
|
||||
const fake = makeFakeAppService();
|
||||
const store = createSettingsCursorStore(fake as unknown as AppService, { debounceMs: 1000 });
|
||||
store.set('dictionary', 'a' as Hlc);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
store.set('dictionary', 'b' as Hlc);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
store.set('dictionary', 'c' as Hlc);
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
expect(fake.saveSettings).toHaveBeenCalledOnce();
|
||||
expect(fake.saveSettings.mock.calls[0]![0].lastSyncedAtReplicas).toEqual({ dictionary: 'c' });
|
||||
});
|
||||
|
||||
test('save preserves other settings fields (load-merge-save round-trip)', async () => {
|
||||
const fake = makeFakeAppService({
|
||||
replicaDeviceId: 'dev-a',
|
||||
keepLogin: true,
|
||||
} as Partial<SystemSettings>);
|
||||
const store = createSettingsCursorStore(fake as unknown as AppService, { debounceMs: 100 });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
store.set('dictionary', 'cur' as Hlc);
|
||||
await vi.advanceTimersByTimeAsync(110);
|
||||
const saved = fake.saveSettings.mock.calls[0]![0];
|
||||
expect(saved.replicaDeviceId).toBe('dev-a');
|
||||
expect(saved.keepLogin).toBe(true);
|
||||
expect(saved.lastSyncedAtReplicas).toEqual({ dictionary: 'cur' });
|
||||
});
|
||||
|
||||
test('save preserves cursors for other kinds', async () => {
|
||||
const fake = makeFakeAppService({
|
||||
lastSyncedAtReplicas: { font: 'cur-font' },
|
||||
});
|
||||
const store = createSettingsCursorStore(fake as unknown as AppService, { debounceMs: 100 });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
store.set('dictionary', 'cur-dict' as Hlc);
|
||||
await vi.advanceTimersByTimeAsync(110);
|
||||
const saved = fake.saveSettings.mock.calls[0]![0];
|
||||
expect(saved.lastSyncedAtReplicas).toEqual({ font: 'cur-font', dictionary: 'cur-dict' });
|
||||
});
|
||||
|
||||
test('save error does not throw to caller (best-effort)', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const fake = makeFakeAppService();
|
||||
fake.saveSettings.mockRejectedValueOnce(new Error('disk full'));
|
||||
const store = createSettingsCursorStore(fake as unknown as AppService, { debounceMs: 100 });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(() => store.set('dictionary', 'cur' as Hlc)).not.toThrow();
|
||||
await vi.advanceTimersByTimeAsync(110);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
test('hydrate failure does not break subsequent get/set', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const fake = makeFakeAppService();
|
||||
fake.loadSettings.mockRejectedValueOnce(new Error('parse error'));
|
||||
const store = createSettingsCursorStore(fake as unknown as AppService);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(store.get('dictionary')).toBe(null);
|
||||
store.set('dictionary', 'cur' as Hlc);
|
||||
expect(store.get('dictionary')).toBe('cur');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildLocalDictFromRow,
|
||||
filesFromManifest,
|
||||
unwrapDictionaryFields,
|
||||
} from '@/services/sync/replicaDictionaryApply';
|
||||
import { hlcPack } from '@/libs/crdt';
|
||||
import type { Hlc, Manifest, ReplicaRow } from '@/types/replica';
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
const DEV = 'dev-a';
|
||||
|
||||
const baseRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
|
||||
user_id: 'u1',
|
||||
kind: 'dictionary',
|
||||
replica_id: 'content-hash-abc',
|
||||
fields_jsonb: {
|
||||
name: { v: 'Webster', t: hlcPack(NOW, 0, DEV) as Hlc, s: DEV },
|
||||
kind: { v: 'mdict', t: hlcPack(NOW, 1, DEV) as Hlc, s: DEV },
|
||||
lang: { v: 'en', t: hlcPack(NOW, 2, DEV) as Hlc, s: DEV },
|
||||
addedAt: { v: 1700000000000, t: hlcPack(NOW, 3, DEV) as Hlc, s: DEV },
|
||||
},
|
||||
manifest_jsonb: null,
|
||||
deleted_at_ts: null,
|
||||
reincarnation: null,
|
||||
updated_at_ts: hlcPack(NOW, 3, DEV) as Hlc,
|
||||
schema_version: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const manifest = (
|
||||
files: { filename: string; byteSize?: number; partialMd5?: string }[],
|
||||
): Manifest => ({
|
||||
files: files.map((f) => ({
|
||||
filename: f.filename,
|
||||
byteSize: f.byteSize ?? 0,
|
||||
partialMd5: f.partialMd5 ?? '',
|
||||
})),
|
||||
schemaVersion: 1,
|
||||
});
|
||||
|
||||
describe('unwrapDictionaryFields', () => {
|
||||
test('extracts plain values from envelope fields', () => {
|
||||
const row = baseRow();
|
||||
expect(unwrapDictionaryFields(row.fields_jsonb)).toEqual({
|
||||
name: 'Webster',
|
||||
kind: 'mdict',
|
||||
lang: 'en',
|
||||
addedAt: 1700000000000,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns undefined for missing fields', () => {
|
||||
const row = baseRow();
|
||||
delete (row.fields_jsonb as Record<string, unknown>)['lang'];
|
||||
expect(unwrapDictionaryFields(row.fields_jsonb).lang).toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles unsupportedReason if present', () => {
|
||||
const row = baseRow();
|
||||
row.fields_jsonb['unsupportedReason'] = {
|
||||
v: 'encrypted',
|
||||
t: hlcPack(NOW, 4, DEV) as Hlc,
|
||||
s: DEV,
|
||||
};
|
||||
row.fields_jsonb['unsupported'] = { v: true, t: hlcPack(NOW, 5, DEV) as Hlc, s: DEV };
|
||||
const fields = unwrapDictionaryFields(row.fields_jsonb);
|
||||
expect(fields.unsupported).toBe(true);
|
||||
expect(fields.unsupportedReason).toBe('encrypted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filesFromManifest', () => {
|
||||
test('mdict manifest: classifies mdx + mdd + css', () => {
|
||||
const m = manifest([
|
||||
{ filename: 'webster.mdx' },
|
||||
{ filename: 'webster.mdd' },
|
||||
{ filename: 'webster.1.mdd' },
|
||||
{ filename: 'webster.css' },
|
||||
]);
|
||||
expect(filesFromManifest(m, 'mdict')).toEqual({
|
||||
mdx: 'webster.mdx',
|
||||
mdd: ['webster.mdd', 'webster.1.mdd'],
|
||||
css: ['webster.css'],
|
||||
});
|
||||
});
|
||||
|
||||
test('stardict manifest: classifies ifo + idx + dict + syn (skips offsets)', () => {
|
||||
const m = manifest([
|
||||
{ filename: 'd.ifo' },
|
||||
{ filename: 'd.idx' },
|
||||
{ filename: 'd.dict.dz' },
|
||||
{ filename: 'd.syn' },
|
||||
]);
|
||||
expect(filesFromManifest(m, 'stardict')).toEqual({
|
||||
ifo: 'd.ifo',
|
||||
idx: 'd.idx',
|
||||
dict: 'd.dict.dz',
|
||||
syn: 'd.syn',
|
||||
});
|
||||
});
|
||||
|
||||
test('dict manifest: classifies dict + index', () => {
|
||||
const m = manifest([{ filename: 'w.dict.dz' }, { filename: 'w.index' }]);
|
||||
expect(filesFromManifest(m, 'dict')).toEqual({
|
||||
dict: 'w.dict.dz',
|
||||
index: 'w.index',
|
||||
});
|
||||
});
|
||||
|
||||
test('slob manifest: classifies single .slob', () => {
|
||||
const m = manifest([{ filename: 'w.slob' }]);
|
||||
expect(filesFromManifest(m, 'slob')).toEqual({ slob: 'w.slob' });
|
||||
});
|
||||
|
||||
test('null manifest returns empty files object', () => {
|
||||
expect(filesFromManifest(null, 'mdict')).toEqual({});
|
||||
});
|
||||
|
||||
test('empty manifest returns empty files object', () => {
|
||||
expect(filesFromManifest(manifest([]), 'mdict')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLocalDictFromRow', () => {
|
||||
test('builds a complete ImportedDictionary from a row + bundleDir', () => {
|
||||
const row = baseRow({
|
||||
manifest_jsonb: manifest([
|
||||
{ filename: 'webster.mdx', byteSize: 1000 },
|
||||
{ filename: 'webster.mdd', byteSize: 5000 },
|
||||
]),
|
||||
});
|
||||
const dict = buildLocalDictFromRow(row, 'local-bundle-1');
|
||||
expect(dict).not.toBe(null);
|
||||
expect(dict!.id).toBe('local-bundle-1');
|
||||
expect(dict!.contentId).toBe('content-hash-abc');
|
||||
expect(dict!.kind).toBe('mdict');
|
||||
expect(dict!.name).toBe('Webster');
|
||||
expect(dict!.lang).toBe('en');
|
||||
expect(dict!.addedAt).toBe(1700000000000);
|
||||
expect(dict!.bundleDir).toBe('local-bundle-1');
|
||||
expect(dict!.files).toEqual({ mdx: 'webster.mdx', mdd: ['webster.mdd'] });
|
||||
expect(dict!.unavailable).toBe(true);
|
||||
});
|
||||
|
||||
test('null manifest produces an empty files object (still unavailable)', () => {
|
||||
const row = baseRow({ manifest_jsonb: null });
|
||||
const dict = buildLocalDictFromRow(row, 'b');
|
||||
expect(dict!.files).toEqual({});
|
||||
expect(dict!.unavailable).toBe(true);
|
||||
});
|
||||
|
||||
test('returns null when fields are malformed (missing kind)', () => {
|
||||
const row = baseRow();
|
||||
delete (row.fields_jsonb as Record<string, unknown>)['kind'];
|
||||
expect(buildLocalDictFromRow(row, 'b')).toBe(null);
|
||||
});
|
||||
|
||||
test('returns null when fields are malformed (missing name)', () => {
|
||||
const row = baseRow();
|
||||
delete (row.fields_jsonb as Record<string, unknown>)['name'];
|
||||
expect(buildLocalDictFromRow(row, 'b')).toBe(null);
|
||||
});
|
||||
|
||||
test('propagates reincarnation token from the row to the dict', () => {
|
||||
const row = baseRow({ reincarnation: 'epoch-1' });
|
||||
expect(buildLocalDictFromRow(row, 'b')!.reincarnation).toBe('epoch-1');
|
||||
});
|
||||
|
||||
test('propagates unsupported flags', () => {
|
||||
const row = baseRow();
|
||||
row.fields_jsonb['unsupported'] = { v: true, t: hlcPack(NOW, 4, DEV) as Hlc, s: DEV };
|
||||
row.fields_jsonb['unsupportedReason'] = {
|
||||
v: 'encrypted MDX',
|
||||
t: hlcPack(NOW, 5, DEV) as Hlc,
|
||||
s: DEV,
|
||||
};
|
||||
const dict = buildLocalDictFromRow(row, 'b')!;
|
||||
expect(dict.unsupported).toBe(true);
|
||||
expect(dict.unsupportedReason).toBe('encrypted MDX');
|
||||
});
|
||||
|
||||
test('falls back to current time when addedAt missing', () => {
|
||||
const row = baseRow();
|
||||
delete (row.fields_jsonb as Record<string, unknown>)['addedAt'];
|
||||
const before = Date.now();
|
||||
const dict = buildLocalDictFromRow(row, 'b')!;
|
||||
const after = Date.now();
|
||||
expect(dict.addedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(dict.addedAt).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/utils/access', () => ({
|
||||
getUserID: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/sync/replicaSync', () => ({
|
||||
getReplicaSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getUserID } from '@/utils/access';
|
||||
import { getReplicaSync } from '@/services/sync/replicaSync';
|
||||
import {
|
||||
publishDictionaryDelete,
|
||||
publishDictionaryManifest,
|
||||
publishDictionaryUpsert,
|
||||
} from '@/services/sync/replicaPublish';
|
||||
import type { ImportedDictionary } from '@/services/dictionaries/types';
|
||||
import { HlcGenerator, hlcPack } from '@/libs/crdt';
|
||||
import type { Hlc, ReplicaRow } from '@/types/replica';
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
const DEV = 'dev-a';
|
||||
|
||||
const baseDict = (overrides: Partial<ImportedDictionary> = {}): ImportedDictionary => ({
|
||||
id: 'bundle-dir-xyz',
|
||||
contentId: 'content-hash-abc',
|
||||
kind: 'mdict',
|
||||
name: 'Webster',
|
||||
bundleDir: 'bundle-dir-xyz',
|
||||
files: { mdx: 'webster.mdx' },
|
||||
addedAt: NOW,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeFakeCtx = () => {
|
||||
const hlc = new HlcGenerator(DEV, () => NOW);
|
||||
const manager = {
|
||||
markDirty: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
pull: vi.fn(),
|
||||
startAutoSync: vi.fn(),
|
||||
stopAutoSync: vi.fn(),
|
||||
pendingCount: vi.fn(() => 0),
|
||||
pendingKeys: vi.fn(() => []),
|
||||
};
|
||||
return { manager, hlc, deviceId: DEV };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('publishDictionaryUpsert', () => {
|
||||
test('no-ops when replicaSync is not initialized', async () => {
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
await publishDictionaryUpsert(baseDict());
|
||||
});
|
||||
|
||||
test('no-ops when contentId is absent (legacy bundle)', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
await publishDictionaryUpsert(baseDict({ contentId: undefined }));
|
||||
expect(ctx.manager.markDirty).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('no-ops when user not authenticated', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
await publishDictionaryUpsert(baseDict());
|
||||
expect(ctx.manager.markDirty).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('builds + markDirty a kind=dictionary row keyed by contentId', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
const dict = baseDict({ name: 'Webster Concise', lang: 'en' });
|
||||
await publishDictionaryUpsert(dict);
|
||||
|
||||
expect(ctx.manager.markDirty).toHaveBeenCalledOnce();
|
||||
const row = ctx.manager.markDirty.mock.calls[0]![0];
|
||||
expect(row.user_id).toBe('user-1');
|
||||
expect(row.kind).toBe('dictionary');
|
||||
expect(row.replica_id).toBe('content-hash-abc');
|
||||
expect(row.fields_jsonb['name']?.v).toBe('Webster Concise');
|
||||
expect(row.fields_jsonb['lang']?.v).toBe('en');
|
||||
expect(row.fields_jsonb['kind']?.v).toBe('mdict');
|
||||
expect(row.deleted_at_ts).toBe(null);
|
||||
expect(row.schema_version).toBe(1);
|
||||
});
|
||||
|
||||
test('every field gets a fresh HLC stamp + deviceId', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
await publishDictionaryUpsert(baseDict());
|
||||
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
|
||||
for (const env of Object.values(row.fields_jsonb)) {
|
||||
expect(env.s).toBe(DEV);
|
||||
expect(env.t).toMatch(/^[0-9a-f]+-[0-9a-f]+-dev-a$/);
|
||||
}
|
||||
});
|
||||
|
||||
test('updated_at_ts is the maximum of all field HLCs', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
await publishDictionaryUpsert(baseDict());
|
||||
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
|
||||
const fieldHlcs = Object.values(row.fields_jsonb).map((e) => e.t);
|
||||
const maxField = fieldHlcs.reduce((a, b) => (a > b ? a : b));
|
||||
expect(row.updated_at_ts >= maxField).toBe(true);
|
||||
});
|
||||
|
||||
test('reincarnation field on the dict propagates to the row (revives a tombstoned row)', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
await publishDictionaryUpsert(baseDict({ reincarnation: 'epoch-1' }));
|
||||
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
|
||||
expect(row.reincarnation).toBe('epoch-1');
|
||||
});
|
||||
|
||||
test('reincarnation defaults to null when absent on the dict (first import or live re-import)', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
await publishDictionaryUpsert(baseDict());
|
||||
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
|
||||
expect(row.reincarnation).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishDictionaryDelete', () => {
|
||||
test('no-ops when replicaSync is not initialized', async () => {
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
await publishDictionaryDelete('content-hash-abc');
|
||||
});
|
||||
|
||||
test('no-ops when user not authenticated', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
await publishDictionaryDelete('content-hash-abc');
|
||||
expect(ctx.manager.markDirty).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('produces a tombstoned row (deleted_at_ts set)', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
await publishDictionaryDelete('content-hash-abc');
|
||||
expect(ctx.manager.markDirty).toHaveBeenCalledOnce();
|
||||
const row = ctx.manager.markDirty.mock.calls[0]![0];
|
||||
expect(row.replica_id).toBe('content-hash-abc');
|
||||
expect(row.kind).toBe('dictionary');
|
||||
expect(row.deleted_at_ts).not.toBe(null);
|
||||
});
|
||||
|
||||
test('tombstone HLC matches updated_at_ts (remove-wins ordering)', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
await publishDictionaryDelete('content-hash-abc');
|
||||
const row = ctx.manager.markDirty.mock.calls[0]![0];
|
||||
expect(row.updated_at_ts).toBe(row.deleted_at_ts);
|
||||
});
|
||||
});
|
||||
describe('publishDictionaryManifest', () => {
|
||||
test('no-ops when replicaSync is not initialized', async () => {
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
await publishDictionaryManifest('content-hash-abc', []);
|
||||
});
|
||||
|
||||
test('no-ops when user not authenticated', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
await publishDictionaryManifest('content-hash-abc', []);
|
||||
expect(ctx.manager.markDirty).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('produces a row with manifest_jsonb populated and empty fields_jsonb', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
const files = [
|
||||
{ filename: 'webster.mdx', byteSize: 1_000_000, partialMd5: 'abc123' },
|
||||
{ filename: 'webster.mdd', byteSize: 5_000_000, partialMd5: 'def456' },
|
||||
];
|
||||
await publishDictionaryManifest('content-hash-abc', files);
|
||||
expect(ctx.manager.markDirty).toHaveBeenCalledOnce();
|
||||
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
|
||||
expect(row.replica_id).toBe('content-hash-abc');
|
||||
expect(row.kind).toBe('dictionary');
|
||||
expect(row.manifest_jsonb).toEqual({ files, schemaVersion: 1 });
|
||||
expect(row.fields_jsonb).toEqual({});
|
||||
expect(row.deleted_at_ts).toBe(null);
|
||||
});
|
||||
|
||||
test('manifest publish preserves reincarnation when provided', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
await publishDictionaryManifest('content-hash-abc', [], 'epoch-1');
|
||||
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
|
||||
expect(row.reincarnation).toBe('epoch-1');
|
||||
});
|
||||
|
||||
test('manifest with no files is valid (e.g., metadata-only refresh)', async () => {
|
||||
const ctx = makeFakeCtx();
|
||||
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
|
||||
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
|
||||
await publishDictionaryManifest('content-hash-abc', []);
|
||||
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
|
||||
expect(row.manifest_jsonb?.files).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// Suppress unused import lint when running standalone
|
||||
void hlcPack;
|
||||
void ({} as Hlc);
|
||||
@@ -0,0 +1,319 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
pullDictionariesAndApply,
|
||||
type PullDictionariesDeps,
|
||||
} from '@/services/sync/replicaPullDictionaries';
|
||||
import { hlcPack } from '@/libs/crdt';
|
||||
import type { Hlc, Manifest, ReplicaRow } from '@/types/replica';
|
||||
import type { ImportedDictionary } from '@/services/dictionaries/types';
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
const DEV = 'dev-a';
|
||||
|
||||
const baseRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
|
||||
user_id: 'u1',
|
||||
kind: 'dictionary',
|
||||
replica_id: 'content-hash-abc',
|
||||
fields_jsonb: {
|
||||
name: { v: 'Webster', t: hlcPack(NOW, 0, DEV) as Hlc, s: DEV },
|
||||
kind: { v: 'mdict', t: hlcPack(NOW, 1, DEV) as Hlc, s: DEV },
|
||||
addedAt: { v: NOW, t: hlcPack(NOW, 2, DEV) as Hlc, s: DEV },
|
||||
},
|
||||
manifest_jsonb: null,
|
||||
deleted_at_ts: null,
|
||||
reincarnation: null,
|
||||
updated_at_ts: hlcPack(NOW, 2, DEV) as Hlc,
|
||||
schema_version: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const manifest = (filenames: string[]): Manifest => ({
|
||||
files: filenames.map((filename) => ({ filename, byteSize: 1, partialMd5: 'x' })),
|
||||
schemaVersion: 1,
|
||||
});
|
||||
|
||||
const baseDict = (overrides: Partial<ImportedDictionary> = {}): ImportedDictionary => ({
|
||||
id: 'local-1',
|
||||
contentId: 'content-hash-abc',
|
||||
kind: 'mdict',
|
||||
name: 'Webster',
|
||||
bundleDir: 'local-1',
|
||||
files: { mdx: 'webster.mdx' },
|
||||
addedAt: NOW,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeDeps = () => {
|
||||
const findByContentId = vi.fn((_id: string): ImportedDictionary | undefined => undefined);
|
||||
const deps = {
|
||||
pull: vi.fn(async () => [] as ReplicaRow[]),
|
||||
findByContentId,
|
||||
applyRemoteDictionary: vi.fn(),
|
||||
softDeleteByContentId: vi.fn(),
|
||||
createBundleDir: vi.fn(async () => 'fresh-bundle-dir-1'),
|
||||
queueReplicaDownload: vi.fn(() => 'transfer-id-1'),
|
||||
// Default: no files exist locally, so the orchestrator queues
|
||||
// downloads. Tests that exercise the "binaries already on disk"
|
||||
// path override this.
|
||||
filesExist: vi.fn(async () => false),
|
||||
} satisfies PullDictionariesDeps;
|
||||
return deps;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('pullDictionariesAndApply', () => {
|
||||
test('no-op when pull returns no rows', async () => {
|
||||
const deps = makeDeps();
|
||||
await pullDictionariesAndApply(deps);
|
||||
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
|
||||
expect(deps.queueReplicaDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips entirely (no pull) when isAuthenticated returns false', async () => {
|
||||
const deps = {
|
||||
...makeDeps(),
|
||||
isAuthenticated: vi.fn(async () => false),
|
||||
} satisfies PullDictionariesDeps;
|
||||
await pullDictionariesAndApply(deps);
|
||||
expect(deps.isAuthenticated).toHaveBeenCalledOnce();
|
||||
expect(deps.pull).not.toHaveBeenCalled();
|
||||
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
|
||||
expect(deps.queueReplicaDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('hydrateLocalStore runs before pull so applyRemoteDictionary auto-persist does not wipe persisted entries', async () => {
|
||||
const order: string[] = [];
|
||||
const deps = {
|
||||
...makeDeps(),
|
||||
hydrateLocalStore: vi.fn(async () => {
|
||||
order.push('hydrate');
|
||||
}),
|
||||
} satisfies PullDictionariesDeps;
|
||||
(deps.pull as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
||||
order.push('pull');
|
||||
return [];
|
||||
});
|
||||
await pullDictionariesAndApply(deps);
|
||||
expect(order).toEqual(['hydrate', 'pull']);
|
||||
});
|
||||
|
||||
test('proceeds when isAuthenticated returns true', async () => {
|
||||
const row = baseRow({ manifest_jsonb: manifest(['x.mdx']) });
|
||||
const deps = {
|
||||
...makeDeps(),
|
||||
isAuthenticated: vi.fn(async () => true),
|
||||
} satisfies PullDictionariesDeps;
|
||||
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
|
||||
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
await pullDictionariesAndApply(deps);
|
||||
expect(deps.pull).toHaveBeenCalledOnce();
|
||||
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('alive-and-new row: creates bundle dir, applies dict, queues download', async () => {
|
||||
const row = baseRow({ manifest_jsonb: manifest(['webster.mdx', 'webster.mdd']) });
|
||||
const deps = makeDeps();
|
||||
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
|
||||
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
|
||||
await pullDictionariesAndApply(deps);
|
||||
|
||||
expect(deps.createBundleDir).toHaveBeenCalledOnce();
|
||||
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
|
||||
const applied = (deps.applyRemoteDictionary as ReturnType<typeof vi.fn>).mock.calls[0]![0];
|
||||
expect(applied.contentId).toBe('content-hash-abc');
|
||||
expect(applied.bundleDir).toBe('fresh-bundle-dir-1');
|
||||
expect(applied.unavailable).toBe(true);
|
||||
|
||||
expect(deps.queueReplicaDownload).toHaveBeenCalledOnce();
|
||||
const downloadArgs = (deps.queueReplicaDownload as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(downloadArgs![0]).toBe('content-hash-abc');
|
||||
expect(downloadArgs![1]).toBe('Webster');
|
||||
expect(downloadArgs![2]).toEqual([
|
||||
{ logical: 'webster.mdx', lfp: 'fresh-bundle-dir-1/webster.mdx', byteSize: 1 },
|
||||
{ logical: 'webster.mdd', lfp: 'fresh-bundle-dir-1/webster.mdd', byteSize: 1 },
|
||||
]);
|
||||
expect(downloadArgs![3]).toBe('fresh-bundle-dir-1');
|
||||
});
|
||||
|
||||
test('alive-and-new row WITHOUT manifest: applies dict but skips download (binaries pending server-side)', async () => {
|
||||
const row = baseRow({ manifest_jsonb: null });
|
||||
const deps = makeDeps();
|
||||
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
|
||||
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
|
||||
await pullDictionariesAndApply(deps);
|
||||
|
||||
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
|
||||
expect(deps.queueReplicaDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('alive-and-already-local row WITH local binaries: does NOT re-create or re-download', async () => {
|
||||
const row = baseRow({ manifest_jsonb: manifest(['webster.mdx']) });
|
||||
const local = baseDict();
|
||||
const deps = makeDeps();
|
||||
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
|
||||
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(local);
|
||||
(deps.filesExist as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
|
||||
await pullDictionariesAndApply(deps);
|
||||
|
||||
expect(deps.createBundleDir).not.toHaveBeenCalled();
|
||||
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
|
||||
expect(deps.filesExist).toHaveBeenCalledWith('local-1', ['webster.mdx']);
|
||||
expect(deps.queueReplicaDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('alive-and-already-local row WITH binaries missing: re-downloads into the existing bundleDir', async () => {
|
||||
const row = baseRow({ manifest_jsonb: manifest(['webster.mdx', 'webster.mdd']) });
|
||||
const local = baseDict();
|
||||
const deps = makeDeps();
|
||||
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
|
||||
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(local);
|
||||
// Default filesExist returns false → recovery path.
|
||||
|
||||
await pullDictionariesAndApply(deps);
|
||||
|
||||
expect(deps.createBundleDir).not.toHaveBeenCalled();
|
||||
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
|
||||
expect(deps.queueReplicaDownload).toHaveBeenCalledOnce();
|
||||
const downloadArgs = (deps.queueReplicaDownload as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
// bundleDir is the existing local entry's, NOT a fresh one.
|
||||
expect(downloadArgs![3]).toBe('local-1');
|
||||
expect(downloadArgs![2]).toEqual([
|
||||
{ logical: 'webster.mdx', lfp: 'local-1/webster.mdx', byteSize: 1 },
|
||||
{ logical: 'webster.mdd', lfp: 'local-1/webster.mdd', byteSize: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('alive-and-new row WITH binaries already on disk: applies but does NOT queue download', async () => {
|
||||
const row = baseRow({ manifest_jsonb: manifest(['webster.mdx']) });
|
||||
const deps = makeDeps();
|
||||
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
|
||||
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
(deps.filesExist as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
|
||||
await pullDictionariesAndApply(deps);
|
||||
|
||||
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
|
||||
expect(deps.queueReplicaDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('tombstoned row (no reincarnation): soft-delete the local entry if alive', async () => {
|
||||
const tombstone = hlcPack(NOW + 1000, 0, DEV) as Hlc;
|
||||
const row = baseRow({
|
||||
deleted_at_ts: tombstone,
|
||||
updated_at_ts: tombstone,
|
||||
reincarnation: null,
|
||||
});
|
||||
const local = baseDict();
|
||||
const deps = makeDeps();
|
||||
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
|
||||
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(local);
|
||||
|
||||
await pullDictionariesAndApply(deps);
|
||||
|
||||
expect(deps.softDeleteByContentId).toHaveBeenCalledWith('content-hash-abc');
|
||||
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('tombstoned row already gone locally: no-op (idempotent)', async () => {
|
||||
const tombstone = hlcPack(NOW + 1000, 0, DEV) as Hlc;
|
||||
const row = baseRow({
|
||||
deleted_at_ts: tombstone,
|
||||
updated_at_ts: tombstone,
|
||||
reincarnation: null,
|
||||
});
|
||||
const deps = makeDeps();
|
||||
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
|
||||
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
|
||||
await pullDictionariesAndApply(deps);
|
||||
|
||||
expect(deps.softDeleteByContentId).not.toHaveBeenCalled();
|
||||
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reincarnated row (alive again): treated as alive — creates locally if absent', async () => {
|
||||
const tombstone = hlcPack(NOW, 0, DEV) as Hlc;
|
||||
const row = baseRow({
|
||||
deleted_at_ts: tombstone,
|
||||
reincarnation: 'epoch-1',
|
||||
manifest_jsonb: manifest(['webster.mdx']),
|
||||
updated_at_ts: hlcPack(NOW + 1000, 0, DEV) as Hlc,
|
||||
});
|
||||
const deps = makeDeps();
|
||||
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
|
||||
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
|
||||
await pullDictionariesAndApply(deps);
|
||||
|
||||
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
|
||||
const applied = (deps.applyRemoteDictionary as ReturnType<typeof vi.fn>).mock.calls[0]![0];
|
||||
expect(applied.reincarnation).toBe('epoch-1');
|
||||
expect(deps.queueReplicaDownload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('malformed row (missing kind): skipped, no apply, no download', async () => {
|
||||
const row = baseRow();
|
||||
delete (row.fields_jsonb as Record<string, unknown>)['kind'];
|
||||
const deps = makeDeps();
|
||||
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
|
||||
|
||||
await pullDictionariesAndApply(deps);
|
||||
|
||||
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
|
||||
expect(deps.queueReplicaDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('multiple rows: each applied independently', async () => {
|
||||
const r1 = baseRow({
|
||||
replica_id: 'hash-A',
|
||||
fields_jsonb: {
|
||||
...baseRow().fields_jsonb,
|
||||
name: { v: 'Dict A', t: hlcPack(NOW, 0, DEV) as Hlc, s: DEV },
|
||||
},
|
||||
manifest_jsonb: manifest(['a.mdx']),
|
||||
});
|
||||
const r2 = baseRow({
|
||||
replica_id: 'hash-B',
|
||||
fields_jsonb: {
|
||||
...baseRow().fields_jsonb,
|
||||
name: { v: 'Dict B', t: hlcPack(NOW, 0, DEV) as Hlc, s: DEV },
|
||||
},
|
||||
manifest_jsonb: manifest(['b.mdx']),
|
||||
});
|
||||
const deps = makeDeps();
|
||||
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([r1, r2]);
|
||||
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
let bundleCounter = 0;
|
||||
(deps.createBundleDir as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async () => `bundle-${++bundleCounter}`,
|
||||
);
|
||||
|
||||
await pullDictionariesAndApply(deps);
|
||||
|
||||
expect(deps.applyRemoteDictionary).toHaveBeenCalledTimes(2);
|
||||
expect(deps.queueReplicaDownload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('one malformed row does not block others', async () => {
|
||||
const goodRow = baseRow({ manifest_jsonb: manifest(['x.mdx']) });
|
||||
const badRow = baseRow({ replica_id: 'hash-bad' });
|
||||
delete (badRow.fields_jsonb as Record<string, unknown>)['kind'];
|
||||
const deps = makeDeps();
|
||||
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([badRow, goodRow]);
|
||||
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
|
||||
await pullDictionariesAndApply(deps);
|
||||
|
||||
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -107,6 +107,92 @@ describe('ReplicaSyncManager.markDirty + flush', () => {
|
||||
expect(pushed[0]!.fields_jsonb['name']!.t).toBe(hlcPack(NOW, 1, DEV));
|
||||
});
|
||||
|
||||
test('same replica metadata + manifest rows coalesce before push', async () => {
|
||||
const { manager, client } = makeManager();
|
||||
const fieldsHlc = hlcPack(NOW, 0, DEV) as Hlc;
|
||||
const manifestHlc = hlcPack(NOW, 1, DEV) as Hlc;
|
||||
manager.markDirty(makeRow('r1', fieldsHlc));
|
||||
manager.markDirty({
|
||||
...makeRow('r1', manifestHlc),
|
||||
fields_jsonb: {},
|
||||
manifest_jsonb: {
|
||||
schemaVersion: 1,
|
||||
files: [{ filename: 'webster.mdx', byteSize: 1000, partialMd5: 'a'.repeat(32) }],
|
||||
},
|
||||
reincarnation: 'epoch-1',
|
||||
});
|
||||
|
||||
await manager.flush();
|
||||
|
||||
const pushed = client.push.mock.calls[0]![0];
|
||||
expect(pushed).toHaveLength(1);
|
||||
expect(pushed[0]!.fields_jsonb['name']?.v).toBe('r1');
|
||||
expect(pushed[0]!.manifest_jsonb?.files).toHaveLength(1);
|
||||
expect(pushed[0]!.reincarnation).toBe('epoch-1');
|
||||
expect(pushed[0]!.updated_at_ts).toBe(manifestHlc);
|
||||
});
|
||||
|
||||
test('coalescing metadata after manifest preserves the manifest', async () => {
|
||||
const { manager, client } = makeManager();
|
||||
const manifestHlc = hlcPack(NOW, 0, DEV) as Hlc;
|
||||
const metadataHlc = hlcPack(NOW, 1, DEV) as Hlc;
|
||||
manager.markDirty({
|
||||
...makeRow('r1', manifestHlc),
|
||||
manifest_jsonb: {
|
||||
schemaVersion: 1,
|
||||
files: [{ filename: 'webster.mdx', byteSize: 1000, partialMd5: 'a'.repeat(32) }],
|
||||
},
|
||||
});
|
||||
manager.markDirty({
|
||||
...makeRow('r1', metadataHlc),
|
||||
fields_jsonb: { name: { v: 'Renamed', t: metadataHlc, s: DEV } },
|
||||
manifest_jsonb: null,
|
||||
});
|
||||
|
||||
await manager.flush();
|
||||
|
||||
const pushed = client.push.mock.calls[0]![0];
|
||||
expect(pushed[0]!.fields_jsonb['name']?.v).toBe('Renamed');
|
||||
expect(pushed[0]!.manifest_jsonb?.files).toHaveLength(1);
|
||||
expect(pushed[0]!.updated_at_ts).toBe(metadataHlc);
|
||||
});
|
||||
|
||||
test('coalescing metadata with null reincarnation stays null when no token exists', async () => {
|
||||
const { manager, client } = makeManager();
|
||||
const revivedHlc = hlcPack(NOW, 0, DEV) as Hlc;
|
||||
const metadataHlc = hlcPack(NOW, 1, DEV) as Hlc;
|
||||
manager.markDirty(makeRow('r1', revivedHlc));
|
||||
manager.markDirty({
|
||||
...makeRow('r1', metadataHlc),
|
||||
fields_jsonb: { name: { v: 'Renamed', t: metadataHlc, s: DEV } },
|
||||
reincarnation: null,
|
||||
});
|
||||
|
||||
await manager.flush();
|
||||
|
||||
const pushed = client.push.mock.calls[0]![0];
|
||||
expect(pushed[0]!.fields_jsonb['name']?.v).toBe('Renamed');
|
||||
expect(pushed[0]!.reincarnation).toBe(null);
|
||||
});
|
||||
|
||||
test('coalescing metadata after a reincarnated row preserves the token', async () => {
|
||||
const { manager, client } = makeManager();
|
||||
const revivedHlc = hlcPack(NOW, 0, DEV) as Hlc;
|
||||
const metadataHlc = hlcPack(NOW, 1, DEV) as Hlc;
|
||||
manager.markDirty({ ...makeRow('r1', revivedHlc), reincarnation: 'epoch-1' });
|
||||
manager.markDirty({
|
||||
...makeRow('r1', metadataHlc),
|
||||
fields_jsonb: { name: { v: 'Renamed', t: metadataHlc, s: DEV } },
|
||||
reincarnation: null,
|
||||
});
|
||||
|
||||
await manager.flush();
|
||||
|
||||
const pushed = client.push.mock.calls[0]![0];
|
||||
expect(pushed[0]!.fields_jsonb['name']?.v).toBe('Renamed');
|
||||
expect(pushed[0]!.reincarnation).toBe('epoch-1');
|
||||
});
|
||||
|
||||
test('flush() clears the dirty set on success', async () => {
|
||||
const { manager, client } = makeManager();
|
||||
manager.markDirty(makeRow('r1'));
|
||||
@@ -180,4 +266,31 @@ describe('ReplicaSyncManager.pull', () => {
|
||||
await manager.pull('dictionary');
|
||||
expect(cursors.get('dictionary')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('pull with { since: null } bypasses an existing cursor', async () => {
|
||||
const r1 = makeRow('r1', hlcPack(NOW + 100, 0, DEV) as Hlc);
|
||||
const client = {
|
||||
...makeFakeClient(),
|
||||
pull: vi.fn().mockResolvedValueOnce([r1]).mockResolvedValueOnce([r1]),
|
||||
};
|
||||
const { manager } = makeManager(client);
|
||||
await manager.pull('dictionary');
|
||||
// After the first pull, the cursor is at r1.updated_at_ts. A normal
|
||||
// second pull would pass that cursor; the boot pull explicitly
|
||||
// requests since=null to do a full re-sync.
|
||||
await manager.pull('dictionary', { since: null });
|
||||
expect(client.pull).toHaveBeenNthCalledWith(2, 'dictionary', null);
|
||||
});
|
||||
|
||||
test('full pull still advances the cursor when rows arrive', async () => {
|
||||
const r1 = makeRow('r1', hlcPack(NOW + 100, 0, DEV) as Hlc);
|
||||
const r2 = makeRow('r2', hlcPack(NOW + 200, 0, DEV) as Hlc);
|
||||
const client = {
|
||||
...makeFakeClient(),
|
||||
pull: vi.fn(async () => [r1, r2]),
|
||||
};
|
||||
const { manager, cursors } = makeManager(client);
|
||||
await manager.pull('dictionary', { since: null });
|
||||
expect(cursors.get('dictionary')).toBe(r2.updated_at_ts);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/services/sync/replicaPublish', () => ({
|
||||
publishDictionaryManifest: vi.fn(),
|
||||
}));
|
||||
|
||||
const markAvailableByContentId = vi.fn();
|
||||
vi.mock('@/store/customDictionaryStore', () => ({
|
||||
useCustomDictionaryStore: {
|
||||
getState: () => ({ markAvailableByContentId }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { publishDictionaryManifest } from '@/services/sync/replicaPublish';
|
||||
import {
|
||||
__resetReplicaTransferIntegrationForTests,
|
||||
startReplicaTransferIntegration,
|
||||
} from '@/services/sync/replicaTransferIntegration';
|
||||
import type { AppService } from '@/types/system';
|
||||
|
||||
const mockPublish = publishDictionaryManifest as ReturnType<typeof vi.fn>;
|
||||
|
||||
const makeFakeAppService = () => {
|
||||
const close = vi.fn();
|
||||
return {
|
||||
openFile: vi.fn(async (path: string) => {
|
||||
const content = `content-of-${path}`;
|
||||
return new File([content], path, { type: 'application/octet-stream' });
|
||||
}),
|
||||
_close: close,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
__resetReplicaTransferIntegrationForTests();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetReplicaTransferIntegrationForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('replicaTransferIntegration', () => {
|
||||
test('upload event triggers publishDictionaryManifest', async () => {
|
||||
const appService = makeFakeAppService() as unknown as AppService;
|
||||
startReplicaTransferIntegration(appService);
|
||||
mockPublish.mockResolvedValue(undefined);
|
||||
|
||||
await eventDispatcher.dispatch('replica-transfer-complete', {
|
||||
kind: 'dictionary',
|
||||
replicaId: 'content-hash-abc',
|
||||
type: 'upload',
|
||||
files: [
|
||||
{ logical: 'webster.mdx', lfp: 'b/webster.mdx', byteSize: 1000 },
|
||||
{ logical: 'webster.mdd', lfp: 'b/webster.mdd', byteSize: 2000 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(mockPublish).toHaveBeenCalledOnce();
|
||||
const [contentId, manifestFiles] = mockPublish.mock.calls[0]!;
|
||||
expect(contentId).toBe('content-hash-abc');
|
||||
expect(manifestFiles).toHaveLength(2);
|
||||
expect(manifestFiles[0]!.filename).toBe('webster.mdx');
|
||||
expect(manifestFiles[0]!.byteSize).toBe(1000);
|
||||
expect(manifestFiles[0]!.partialMd5).toMatch(/^[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
test('upload event carries reincarnation token into manifest publish', async () => {
|
||||
const appService = makeFakeAppService() as unknown as AppService;
|
||||
startReplicaTransferIntegration(appService);
|
||||
mockPublish.mockResolvedValue(undefined);
|
||||
|
||||
await eventDispatcher.dispatch('replica-transfer-complete', {
|
||||
kind: 'dictionary',
|
||||
replicaId: 'content-hash-abc',
|
||||
reincarnation: 'epoch-1',
|
||||
type: 'upload',
|
||||
files: [{ logical: 'webster.mdx', lfp: 'b/webster.mdx', byteSize: 1000 }],
|
||||
});
|
||||
|
||||
expect(mockPublish).toHaveBeenCalledOnce();
|
||||
expect(mockPublish.mock.calls[0]![2]).toBe('epoch-1');
|
||||
});
|
||||
|
||||
test('download event does NOT publish a manifest (publish is upload-side only)', async () => {
|
||||
const appService = makeFakeAppService() as unknown as AppService;
|
||||
startReplicaTransferIntegration(appService);
|
||||
|
||||
await eventDispatcher.dispatch('replica-transfer-complete', {
|
||||
kind: 'dictionary',
|
||||
replicaId: 'content-hash-abc',
|
||||
type: 'download',
|
||||
files: [{ logical: 'x.mdx', lfp: 'b/x.mdx', byteSize: 10 }],
|
||||
});
|
||||
expect(mockPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('download event marks the local dict available (clears unavailable flag)', async () => {
|
||||
const appService = makeFakeAppService() as unknown as AppService;
|
||||
startReplicaTransferIntegration(appService);
|
||||
|
||||
await eventDispatcher.dispatch('replica-transfer-complete', {
|
||||
kind: 'dictionary',
|
||||
replicaId: 'content-hash-abc',
|
||||
type: 'download',
|
||||
files: [{ logical: 'x.mdx', lfp: 'b/x.mdx', byteSize: 10 }],
|
||||
});
|
||||
expect(markAvailableByContentId).toHaveBeenCalledOnce();
|
||||
expect(markAvailableByContentId).toHaveBeenCalledWith('content-hash-abc');
|
||||
});
|
||||
|
||||
test('upload event does NOT mark available (only download finishes the placeholder lifecycle)', async () => {
|
||||
const appService = makeFakeAppService() as unknown as AppService;
|
||||
startReplicaTransferIntegration(appService);
|
||||
mockPublish.mockResolvedValue(undefined);
|
||||
|
||||
await eventDispatcher.dispatch('replica-transfer-complete', {
|
||||
kind: 'dictionary',
|
||||
replicaId: 'content-hash-abc',
|
||||
type: 'upload',
|
||||
files: [{ logical: 'x.mdx', lfp: 'b/x.mdx', byteSize: 10 }],
|
||||
});
|
||||
expect(markAvailableByContentId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('non-dictionary download event is ignored (no markAvailable)', async () => {
|
||||
const appService = makeFakeAppService() as unknown as AppService;
|
||||
startReplicaTransferIntegration(appService);
|
||||
|
||||
await eventDispatcher.dispatch('replica-transfer-complete', {
|
||||
kind: 'font',
|
||||
replicaId: 'font-hash',
|
||||
type: 'download',
|
||||
files: [{ logical: 'r.ttf', lfp: 'f/r.ttf', byteSize: 1 }],
|
||||
});
|
||||
expect(markAvailableByContentId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('delete event is ignored', async () => {
|
||||
const appService = makeFakeAppService() as unknown as AppService;
|
||||
startReplicaTransferIntegration(appService);
|
||||
|
||||
await eventDispatcher.dispatch('replica-transfer-complete', {
|
||||
kind: 'dictionary',
|
||||
replicaId: 'content-hash-abc',
|
||||
type: 'delete',
|
||||
filenames: ['x.mdx'],
|
||||
});
|
||||
expect(mockPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('non-dictionary kind is ignored (this slice ships dictionary only)', async () => {
|
||||
const appService = makeFakeAppService() as unknown as AppService;
|
||||
startReplicaTransferIntegration(appService);
|
||||
|
||||
await eventDispatcher.dispatch('replica-transfer-complete', {
|
||||
kind: 'font',
|
||||
replicaId: 'font-hash',
|
||||
type: 'upload',
|
||||
files: [{ logical: 'r.ttf', lfp: 'f/r.ttf', byteSize: 1 }],
|
||||
});
|
||||
expect(mockPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('upload event with no files is ignored', async () => {
|
||||
const appService = makeFakeAppService() as unknown as AppService;
|
||||
startReplicaTransferIntegration(appService);
|
||||
|
||||
await eventDispatcher.dispatch('replica-transfer-complete', {
|
||||
kind: 'dictionary',
|
||||
replicaId: 'content-hash-abc',
|
||||
type: 'upload',
|
||||
files: [],
|
||||
});
|
||||
expect(mockPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('start is idempotent — second call does not double-register', async () => {
|
||||
const appService = makeFakeAppService() as unknown as AppService;
|
||||
startReplicaTransferIntegration(appService);
|
||||
startReplicaTransferIntegration(appService);
|
||||
|
||||
await eventDispatcher.dispatch('replica-transfer-complete', {
|
||||
kind: 'dictionary',
|
||||
replicaId: 'content-hash-abc',
|
||||
type: 'upload',
|
||||
files: [{ logical: 'x.mdx', lfp: 'b/x.mdx', byteSize: 100 }],
|
||||
});
|
||||
expect(mockPublish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('publish error is caught (does not bubble up to event dispatcher)', async () => {
|
||||
const appService = makeFakeAppService() as unknown as AppService;
|
||||
startReplicaTransferIntegration(appService);
|
||||
mockPublish.mockRejectedValueOnce(new Error('network outage'));
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await expect(
|
||||
eventDispatcher.dispatch('replica-transfer-complete', {
|
||||
kind: 'dictionary',
|
||||
replicaId: 'content-hash-abc',
|
||||
type: 'upload',
|
||||
files: [{ logical: 'x.mdx', lfp: 'b/x.mdx', byteSize: 1 }],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,12 @@ const resetTransferManager = () => {
|
||||
mgr['updateBook'] = null;
|
||||
mgr['_'] = null;
|
||||
(mgr['abortControllers'] as Map<string, AbortController>).clear();
|
||||
// Re-arm the readyPromise so each test starts with a pending one.
|
||||
let resolveReady: () => void = () => {};
|
||||
mgr['readyPromise'] = new Promise<void>((res) => {
|
||||
resolveReady = res;
|
||||
});
|
||||
mgr['readyResolve'] = resolveReady;
|
||||
};
|
||||
|
||||
const resetTransferStore = () => {
|
||||
@@ -131,6 +137,43 @@ describe('TransferManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── waitUntilReady ───────────────────────────────────────────────
|
||||
describe('waitUntilReady', () => {
|
||||
test('does not resolve before initialize', async () => {
|
||||
let resolved = false;
|
||||
void transferManager.waitUntilReady().then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
// Yield to the microtask queue so any spurious early resolution would land.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(resolved).toBe(false);
|
||||
});
|
||||
|
||||
test('resolves once initialize completes', async () => {
|
||||
let resolved = false;
|
||||
void transferManager.waitUntilReady().then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
const appService = makeAppService();
|
||||
await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn);
|
||||
await Promise.resolve();
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
test('returns an already-resolved promise after initialize', async () => {
|
||||
const appService = makeAppService();
|
||||
await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn);
|
||||
// After init this should resolve in the next microtask, not block.
|
||||
let resolved = false;
|
||||
void transferManager.waitUntilReady().then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── initialize ───────────────────────────────────────────────────
|
||||
describe('initialize', () => {
|
||||
test('loads persisted queue from localStorage', async () => {
|
||||
@@ -702,6 +745,7 @@ describe('TransferManager', () => {
|
||||
'Webster',
|
||||
dictFiles,
|
||||
'Dictionaries',
|
||||
{ reincarnation: 'epoch-1' },
|
||||
);
|
||||
expect(id).toBeTruthy();
|
||||
const t = useTransferStore.getState().transfers[id!]!;
|
||||
@@ -710,6 +754,7 @@ describe('TransferManager', () => {
|
||||
expect(t.replicaId).toBe('d1');
|
||||
expect(t.replicaFiles).toEqual(dictFiles);
|
||||
expect(t.replicaBase).toBe('Dictionaries');
|
||||
expect(t.replicaReincarnation).toBe('epoch-1');
|
||||
expect(t.totalBytes).toBe(5000);
|
||||
});
|
||||
|
||||
@@ -774,6 +819,35 @@ describe('TransferManager', () => {
|
||||
expect(t.replicaFiles).toEqual(dictFiles);
|
||||
});
|
||||
|
||||
test('executing a replica download passes the transfer base to downloadReplicaFile', async () => {
|
||||
const appService = makeAppService();
|
||||
const downloadSpy = vi.fn().mockResolvedValue(undefined);
|
||||
appService['downloadReplicaFile'] = downloadSpy;
|
||||
await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn);
|
||||
|
||||
transferManager.queueReplicaDownload(
|
||||
'dictionary',
|
||||
'content-hash-abc',
|
||||
'Webster',
|
||||
[{ logical: 'webster.mdx', lfp: 'bundle-1/webster.mdx', byteSize: 1000 }],
|
||||
'Dictionaries',
|
||||
);
|
||||
|
||||
// Drain the queue.
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(downloadSpy).toHaveBeenCalledOnce();
|
||||
// Args: (kind, replicaId, filename, lfp, base, onProgress)
|
||||
const call = downloadSpy.mock.calls[0]!;
|
||||
expect(call[0]).toBe('dictionary');
|
||||
expect(call[1]).toBe('content-hash-abc');
|
||||
expect(call[2]).toBe('webster.mdx');
|
||||
expect(call[3]).toBe('bundle-1/webster.mdx');
|
||||
expect(call[4]).toBe('Dictionaries');
|
||||
});
|
||||
|
||||
test('queueReplicaDelete creates a delete-typed transfer with filename list', async () => {
|
||||
const appService = makeAppService();
|
||||
appService['deleteReplicaBundle'] = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getTransferMessages } from '@/services/transferMessages';
|
||||
import type { TransferItem } from '@/store/transferStore';
|
||||
import type { TranslationFunc } from '@/hooks/useTranslation';
|
||||
|
||||
const passthroughT: TranslationFunc = (key, params) => {
|
||||
if (!params) return key;
|
||||
return Object.entries(params).reduce((acc, [k, v]) => acc.replace(`{{${k}}}`, String(v)), key);
|
||||
};
|
||||
|
||||
const baseTransfer = (overrides: Partial<TransferItem> = {}): TransferItem =>
|
||||
({
|
||||
id: 't1',
|
||||
kind: 'book',
|
||||
bookHash: 'h1',
|
||||
bookTitle: 'Moby Dick',
|
||||
type: 'upload',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
transferSpeed: 0,
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
createdAt: 0,
|
||||
priority: 10,
|
||||
isBackground: false,
|
||||
...overrides,
|
||||
}) as TransferItem;
|
||||
|
||||
describe('getTransferMessages', () => {
|
||||
test('book transfer uses "Book" copy', () => {
|
||||
const m = getTransferMessages(baseTransfer({ bookTitle: 'Moby Dick' }), passthroughT);
|
||||
expect(m.success.upload).toBe('Book uploaded: Moby Dick');
|
||||
expect(m.success.download).toBe('Book downloaded: Moby Dick');
|
||||
expect(m.success.delete).toBe('Deleted cloud backup of the book: Moby Dick');
|
||||
expect(m.failure.upload).toBe('Failed to upload book: Moby Dick');
|
||||
expect(m.failure.download).toBe('Failed to download book: Moby Dick');
|
||||
expect(m.failure.delete).toBe('Failed to delete cloud backup of the book: Moby Dick');
|
||||
});
|
||||
|
||||
test('dictionary replica transfer uses "Dictionary" copy', () => {
|
||||
const m = getTransferMessages(
|
||||
baseTransfer({
|
||||
kind: 'replica',
|
||||
replicaKind: 'dictionary',
|
||||
bookHash: '',
|
||||
bookTitle: 'Longman Phrasal Verbs',
|
||||
}),
|
||||
passthroughT,
|
||||
);
|
||||
expect(m.success.upload).toBe('Dictionary uploaded: Longman Phrasal Verbs');
|
||||
expect(m.success.download).toBe('Dictionary downloaded: Longman Phrasal Verbs');
|
||||
expect(m.success.delete).toBe('Deleted cloud copy of the dictionary: Longman Phrasal Verbs');
|
||||
expect(m.failure.upload).toBe('Failed to upload dictionary: Longman Phrasal Verbs');
|
||||
expect(m.failure.download).toBe('Failed to download dictionary: Longman Phrasal Verbs');
|
||||
expect(m.failure.delete).toBe(
|
||||
'Failed to delete cloud copy of the dictionary: Longman Phrasal Verbs',
|
||||
);
|
||||
});
|
||||
|
||||
test('replica transfer with unknown replicaKind falls back to book copy (defensive)', () => {
|
||||
const m = getTransferMessages(
|
||||
baseTransfer({ kind: 'replica', replicaKind: 'font', bookTitle: 'Roboto' }),
|
||||
passthroughT,
|
||||
);
|
||||
// Until 'font' ships explicit copy, the safe fallback is the existing
|
||||
// book strings. Updating per-kind copy is paired with adding the kind.
|
||||
expect(m.success.upload).toBe('Book uploaded: Roboto');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,27 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/services/sync/replicaPublish', () => ({
|
||||
publishDictionaryDelete: vi.fn(),
|
||||
publishDictionaryUpsert: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
useCustomDictionaryStore,
|
||||
enableReplicaAutoPersist,
|
||||
findDictionaryByContentId,
|
||||
} from '@/store/customDictionaryStore';
|
||||
import { BUILTIN_WEB_SEARCH_IDS } from '@/services/dictionaries/types';
|
||||
import { publishDictionaryUpsert } from '@/services/sync/replicaPublish';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import type { EnvConfigType } from '@/services/environment';
|
||||
import type { ImportedDictionary } from '@/services/dictionaries/types';
|
||||
|
||||
const ZERO = (s: string) => s.startsWith('web:builtin:');
|
||||
const mockPublishDictionaryUpsert = vi.mocked(publishDictionaryUpsert);
|
||||
|
||||
describe('customDictionaryStore — web search CRUD', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset state to defaults so tests don't bleed.
|
||||
useCustomDictionaryStore.setState({
|
||||
dictionaries: [],
|
||||
@@ -123,4 +139,158 @@ describe('customDictionaryStore — web search CRUD', () => {
|
||||
// Unknown id: silent no-op.
|
||||
expect(() => updateDictionary('mdict:nope', { name: 'X' })).not.toThrow();
|
||||
});
|
||||
|
||||
describe('replica auto-persist', () => {
|
||||
const baseDict = (overrides: Partial<ImportedDictionary> = {}): ImportedDictionary => ({
|
||||
id: 'remote-bundle-1',
|
||||
contentId: 'content-hash-1',
|
||||
kind: 'mdict',
|
||||
name: 'Remote Webster',
|
||||
bundleDir: 'remote-bundle-1',
|
||||
files: { mdx: 'webster.mdx' },
|
||||
addedAt: 1,
|
||||
unavailable: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupSpyEnv = () => {
|
||||
// saveCustomDictionaries calls setSettings + saveSettings on the
|
||||
// settings store. Spy both to assert the chain fires.
|
||||
const setSettings = vi.spyOn(useSettingsStore.getState(), 'setSettings');
|
||||
const saveSettings = vi
|
||||
.spyOn(useSettingsStore.getState(), 'saveSettings')
|
||||
.mockResolvedValue(undefined);
|
||||
const fakeEnv = { name: 'test-env' } as unknown as EnvConfigType;
|
||||
enableReplicaAutoPersist(fakeEnv);
|
||||
return { setSettings, saveSettings, fakeEnv };
|
||||
};
|
||||
|
||||
it('applyRemoteDictionary persists state via saveCustomDictionaries when env is registered', async () => {
|
||||
const { setSettings, saveSettings, fakeEnv } = setupSpyEnv();
|
||||
useCustomDictionaryStore.getState().applyRemoteDictionary(baseDict());
|
||||
|
||||
// setSettings runs synchronously inside saveCustomDictionaries; the
|
||||
// microtask queue flush makes the fire-and-forget save observable.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(setSettings).toHaveBeenCalled();
|
||||
expect(saveSettings).toHaveBeenCalledWith(fakeEnv, expect.any(Object));
|
||||
const persisted = setSettings.mock.calls.at(-1)![0];
|
||||
expect(persisted.customDictionaries?.some((d) => d.id === 'remote-bundle-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('softDeleteByContentId persists state via saveCustomDictionaries when env is registered', async () => {
|
||||
const { saveSettings } = setupSpyEnv();
|
||||
// Seed an alive dict to be tombstoned.
|
||||
useCustomDictionaryStore.getState().applyRemoteDictionary(baseDict());
|
||||
saveSettings.mockClear();
|
||||
|
||||
useCustomDictionaryStore.getState().softDeleteByContentId('content-hash-1');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(saveSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not persist when env has not been registered', async () => {
|
||||
// Wipe the registry by re-enabling with null-equivalent. We expose
|
||||
// enableReplicaAutoPersist with a nullable arg for test isolation.
|
||||
enableReplicaAutoPersist(null);
|
||||
const setSettings = vi.spyOn(useSettingsStore.getState(), 'setSettings');
|
||||
const saveSettings = vi
|
||||
.spyOn(useSettingsStore.getState(), 'saveSettings')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
useCustomDictionaryStore.getState().applyRemoteDictionary(baseDict());
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(setSettings).not.toHaveBeenCalled();
|
||||
expect(saveSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDictionaryByContentId', () => {
|
||||
it('returns the in-memory dict when present', () => {
|
||||
useCustomDictionaryStore.getState().applyRemoteDictionary({
|
||||
id: 'in-mem-1',
|
||||
contentId: 'hash-1',
|
||||
kind: 'mdict',
|
||||
name: 'In Memory',
|
||||
bundleDir: 'in-mem-1',
|
||||
files: { mdx: 'm.mdx' },
|
||||
addedAt: 1,
|
||||
});
|
||||
const found = findDictionaryByContentId('hash-1');
|
||||
expect(found?.id).toBe('in-mem-1');
|
||||
});
|
||||
|
||||
it('falls back to settings.customDictionaries when in-memory store has no match', () => {
|
||||
// Simulate fresh-boot state: in-memory store empty, but persisted
|
||||
// settings (loaded from disk) carries the dict.
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
customDictionaries: [
|
||||
{
|
||||
id: 'persisted-1',
|
||||
contentId: 'hash-2',
|
||||
kind: 'mdict',
|
||||
name: 'Persisted',
|
||||
bundleDir: 'persisted-1',
|
||||
files: { mdx: 'p.mdx' },
|
||||
addedAt: 1,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
});
|
||||
const found = findDictionaryByContentId('hash-2');
|
||||
expect(found?.id).toBe('persisted-1');
|
||||
});
|
||||
|
||||
it('returns undefined when neither store has it', () => {
|
||||
useSettingsStore.setState({ settings: {} as never });
|
||||
expect(findDictionaryByContentId('hash-nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips tombstoned persisted entries', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
customDictionaries: [
|
||||
{
|
||||
id: 'tombstoned-1',
|
||||
contentId: 'hash-3',
|
||||
kind: 'mdict',
|
||||
name: 'Tombstoned',
|
||||
bundleDir: 'tombstoned-1',
|
||||
files: { mdx: 'p.mdx' },
|
||||
addedAt: 1,
|
||||
deletedAt: 100,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
});
|
||||
expect(findDictionaryByContentId('hash-3')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('updateDictionary preserves reincarnation when publishing a renamed dictionary', () => {
|
||||
const { addDictionary, updateDictionary } = useCustomDictionaryStore.getState();
|
||||
addDictionary({
|
||||
id: 'mdict:abc',
|
||||
contentId: 'content-abc',
|
||||
kind: 'mdict',
|
||||
name: 'Old title',
|
||||
bundleDir: 'abc',
|
||||
files: { mdx: 'abc.mdx' },
|
||||
addedAt: 1,
|
||||
reincarnation: 'epoch-1',
|
||||
});
|
||||
|
||||
mockPublishDictionaryUpsert.mockClear();
|
||||
updateDictionary('mdict:abc', { name: 'New title' });
|
||||
|
||||
expect(mockPublishDictionaryUpsert).toHaveBeenCalledOnce();
|
||||
expect(mockPublishDictionaryUpsert.mock.calls[0]![0]).toMatchObject({
|
||||
name: 'New title',
|
||||
reincarnation: 'epoch-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,7 @@ import LibraryHeader from './components/LibraryHeader';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
import GroupHeader from './components/GroupHeader';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import { useReplicaPull } from '@/hooks/useReplicaPull';
|
||||
import DropIndicator from '@/components/DropIndicator';
|
||||
import SettingsDialog from '@/components/settings/SettingsDialog';
|
||||
import ModalPortal from '@/components/ModalPortal';
|
||||
@@ -114,6 +115,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
|
||||
const { isTransferQueueOpen } = useTransferStore();
|
||||
|
||||
// Library page pulls dictionaries (custom mdict/stardict bundles synced
|
||||
// across devices). Deferred 10s; module-scoped dedup means a later
|
||||
// navigation to the reader won't re-pull the same kind.
|
||||
useReplicaPull({ kinds: ['dictionary'] });
|
||||
const [showCatalogManager, setShowCatalogManager] = useState(
|
||||
searchParams?.get('opds') === 'true',
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { useTransferQueue } from '@/hooks/useTransferQueue';
|
||||
import { useReplicaPull } from '@/hooks/useReplicaPull';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { interceptWindowOpen } from '@/utils/open';
|
||||
import { mountAdditionalFonts } from '@/styles/fonts';
|
||||
@@ -73,6 +74,11 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
useTheme({ systemUIVisible: settings.alwaysShowStatusBar, appThemeColor: 'base-100' });
|
||||
useScreenWakeLock(settings.screenWakeLock);
|
||||
useTransferQueue(libraryLoaded, 5000);
|
||||
// Reader needs custom dictionaries for word-lookup providers. Mounted
|
||||
// here (not in the app-router page wrapper) so the web pages-router
|
||||
// entry at `pages/reader/[ids].tsx` also gets the pull. Module-scoped
|
||||
// dedup means navigating between library and reader doesn't re-pull.
|
||||
useReplicaPull({ kinds: ['dictionary'] });
|
||||
|
||||
useEffect(() => {
|
||||
mountAdditionalFonts(document);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { evictProvider } from '@/services/dictionaries/registry';
|
||||
import { BUILTIN_PROVIDER_IDS } from '@/services/dictionaries/types';
|
||||
import { queueDictionaryBinaryUpload } from '@/services/sync/replicaBinaryUpload';
|
||||
import type { ImportedDictionary, WebSearchEntry } from '@/services/dictionaries/types';
|
||||
import {
|
||||
getBuiltinWebSearch,
|
||||
@@ -389,11 +390,13 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
|
||||
let added = 0;
|
||||
for (const dict of importResult.imported) {
|
||||
addDictionary(dict);
|
||||
if (appService) void queueDictionaryBinaryUpload(dict, appService);
|
||||
added += 1;
|
||||
}
|
||||
let replaced = 0;
|
||||
for (const { oldIds, newDict } of importResult.replacements) {
|
||||
replaceDictionaries(oldIds, newDict);
|
||||
if (appService) void queueDictionaryBinaryUpload(newDict, appService);
|
||||
// Invalidate any cached provider instances for the replaced ids so
|
||||
// their next lookup picks up the new bundle's files.
|
||||
for (const oldId of oldIds) evictProvider(oldId);
|
||||
|
||||
@@ -5,6 +5,10 @@ import { EnvConfigType } from '../services/environment';
|
||||
import { AppService } from '@/types/system';
|
||||
import env from '../services/environment';
|
||||
import { bootstrapReplicaAdapters } from '@/services/sync/replicaBootstrap';
|
||||
import { initReplicaSync } from '@/services/sync/replicaSync';
|
||||
import { createSettingsCursorStore } from '@/services/sync/replicaCursorStore';
|
||||
import { startReplicaTransferIntegration } from '@/services/sync/replicaTransferIntegration';
|
||||
import { enableReplicaAutoPersist } from '@/store/customDictionaryStore';
|
||||
|
||||
interface EnvContextType {
|
||||
envConfig: EnvConfigType;
|
||||
@@ -19,7 +23,23 @@ export const EnvProvider = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
React.useEffect(() => {
|
||||
bootstrapReplicaAdapters();
|
||||
envConfig.getAppService().then((service) => setAppService(service));
|
||||
enableReplicaAutoPersist(envConfig);
|
||||
envConfig.getAppService().then(async (service) => {
|
||||
setAppService(service);
|
||||
try {
|
||||
const settings = await service.loadSettings();
|
||||
if (settings.replicaDeviceId) {
|
||||
const ctx = initReplicaSync({
|
||||
deviceId: settings.replicaDeviceId,
|
||||
cursorStore: createSettingsCursorStore(service),
|
||||
});
|
||||
ctx.manager.startAutoSync();
|
||||
startReplicaTransferIntegration(service);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('replica sync init failed', err);
|
||||
}
|
||||
});
|
||||
window.addEventListener('error', (e) => {
|
||||
if (e.message === 'ResizeObserver loop limit exceeded') {
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useCustomDictionaryStore, findDictionaryByContentId } from '@/store/customDictionaryStore';
|
||||
import { transferManager } from '@/services/transferManager';
|
||||
import { getReplicaSync } from '@/services/sync/replicaSync';
|
||||
import { pullDictionariesAndApply } from '@/services/sync/replicaPullDictionaries';
|
||||
import { getAccessToken } from '@/utils/access';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
import type { EnvConfigType } from '@/services/environment';
|
||||
import type { AppService, BaseDir } from '@/types/system';
|
||||
import type { ReplicaSyncManager } from '@/services/sync/replicaSyncManager';
|
||||
import type { ImportedDictionary } from '@/services/dictionaries/types';
|
||||
import type { ReplicaTransferFile } from '@/store/transferStore';
|
||||
|
||||
export type ReplicaKind = 'dictionary';
|
||||
|
||||
export interface UseReplicaPullOpts {
|
||||
/** Replica kinds this page wants pulled. */
|
||||
kinds: readonly ReplicaKind[];
|
||||
/** Delay before firing the pull. Defaults to 10s — keeps the boot
|
||||
* critical path clean and lets feature mounts hydrate first. */
|
||||
delayMs?: number;
|
||||
}
|
||||
|
||||
const REPLICA_PULL_DEFAULT_DELAY_MS = 10_000;
|
||||
|
||||
// Module-level dedup so navigating between pages (library → reader → …)
|
||||
// doesn't fire a fresh pull every time. Periodic resync is handled by
|
||||
// ReplicaSyncManager.startAutoSync (visibility / online listeners),
|
||||
// which is wired once in EnvContext.
|
||||
const pulledKinds = new Set<ReplicaKind>();
|
||||
|
||||
const buildDictionaryPullDeps = (
|
||||
manager: ReplicaSyncManager,
|
||||
service: AppService,
|
||||
envConfig: EnvConfigType,
|
||||
) => ({
|
||||
// Boot path uses since=null so we always re-fetch and apply locally,
|
||||
// ignoring any previously-advanced cursor. Periodic sync (visibility /
|
||||
// online) goes through manager.pull(kind) which keeps using the cursor.
|
||||
pull: () => manager.pull('dictionary', { since: null }),
|
||||
// The page may mount before loadCustomDictionaries has hydrated the
|
||||
// in-memory store, so the dedup helper falls back to settings.
|
||||
findByContentId: (id: string) => findDictionaryByContentId(id),
|
||||
// Pull-side relies on the in-memory dict store reflecting persisted
|
||||
// state — without this, the auto-persist fired by applyRemoteDictionary
|
||||
// would write back only the just-applied rows and clobber every
|
||||
// persisted dict that hadn't been hydrated by an Annotator/Settings
|
||||
// mount. Library-page refreshes were the visible victim.
|
||||
hydrateLocalStore: () => useCustomDictionaryStore.getState().loadCustomDictionaries(envConfig),
|
||||
applyRemoteDictionary: (dict: ImportedDictionary) =>
|
||||
useCustomDictionaryStore.getState().applyRemoteDictionary(dict),
|
||||
softDeleteByContentId: (id: string) =>
|
||||
useCustomDictionaryStore.getState().softDeleteByContentId(id),
|
||||
createBundleDir: async () => {
|
||||
const id = uniqueId();
|
||||
await service.createDir(id, 'Dictionaries', true);
|
||||
return id;
|
||||
},
|
||||
queueReplicaDownload: (
|
||||
contentId: string,
|
||||
displayTitle: string,
|
||||
files: ReplicaTransferFile[],
|
||||
_bundleDir: string,
|
||||
base: BaseDir,
|
||||
) => transferManager.queueReplicaDownload('dictionary', contentId, displayTitle, files, base),
|
||||
filesExist: async (bundleDir: string, filenames: string[]) => {
|
||||
for (const filename of filenames) {
|
||||
const exists = await service.exists(`${bundleDir}/${filename}`, 'Dictionaries');
|
||||
if (!exists) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
isAuthenticated: async () => !!(await getAccessToken()),
|
||||
});
|
||||
|
||||
const runPullForKind = async (
|
||||
kind: ReplicaKind,
|
||||
service: AppService,
|
||||
envConfig: EnvConfigType,
|
||||
): Promise<void> => {
|
||||
const ctx = getReplicaSync();
|
||||
if (!ctx) return;
|
||||
if (kind === 'dictionary') {
|
||||
const deps = buildDictionaryPullDeps(ctx.manager, service, envConfig);
|
||||
await pullDictionariesAndApply(deps);
|
||||
return;
|
||||
}
|
||||
// Future: dispatch to other per-kind orchestrators here.
|
||||
};
|
||||
|
||||
/**
|
||||
* Schedules a deferred replica pull for the requested kinds. Mount this
|
||||
* on a page that wants those kinds present (library, reader, etc.).
|
||||
*
|
||||
* Per-kind dedup is module-scoped: the first mount that schedules a
|
||||
* pull for `dictionary` claims that kind for the rest of the session;
|
||||
* subsequent mounts (re-navigation, hot reload, parallel pages mounting
|
||||
* simultaneously) skip. ReplicaSyncManager.startAutoSync handles the
|
||||
* "tab regained focus / network came back" resync — this hook is only
|
||||
* for the initial pull each session.
|
||||
*/
|
||||
export const useReplicaPull = ({
|
||||
kinds,
|
||||
delayMs = REPLICA_PULL_DEFAULT_DELAY_MS,
|
||||
}: UseReplicaPullOpts): void => {
|
||||
const { envConfig, appService } = useEnv();
|
||||
// Stable cache key so the effect doesn't re-run when the caller
|
||||
// passes a freshly-allocated array literal each render.
|
||||
const kindsKey = kinds.join(',');
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService) return;
|
||||
const ctx = getReplicaSync();
|
||||
if (!ctx) return;
|
||||
|
||||
const pendingKinds = kinds.filter((k) => !pulledKinds.has(k));
|
||||
if (pendingKinds.length === 0) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
for (const kind of pendingKinds) {
|
||||
if (pulledKinds.has(kind)) continue;
|
||||
// Claim the slot up front so a concurrently-scheduled mount
|
||||
// (e.g., library + reader mounting back-to-back) doesn't double-
|
||||
// pull. On failure we release the slot so a subsequent navigation
|
||||
// can retry.
|
||||
pulledKinds.add(kind);
|
||||
void runPullForKind(kind, appService, envConfig).catch((err) => {
|
||||
console.warn(`replica ${kind} pull failed`, err);
|
||||
pulledKinds.delete(kind);
|
||||
});
|
||||
}
|
||||
}, delayMs);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [kindsKey, appService, envConfig, delayMs]);
|
||||
};
|
||||
|
||||
/** Test seam — clear the per-kind dedup state between specs. */
|
||||
export const __resetReplicaPullForTests = (): void => {
|
||||
pulledKinds.clear();
|
||||
};
|
||||
@@ -66,10 +66,10 @@ a tombstoned row** — that is the unsafe pattern. To revive, use
|
||||
mergeReplica(local, remote):
|
||||
fields_jsonb ← per-field LWW
|
||||
deleted_at_ts ← max(local, remote) (tombstones never disappear)
|
||||
reincarnation ← LWW by row updated_at_ts
|
||||
manifest_jsonb ← whichever side is newer
|
||||
reincarnation ← newer non-null token; null only clears on newer tombstone
|
||||
manifest_jsonb ← newer non-null manifest; null rows do not clear it
|
||||
schema_version ← max
|
||||
updated_at_ts ← max over fields and tombstone
|
||||
updated_at_ts ← max over fields, tombstone, and row-level ops
|
||||
```
|
||||
|
||||
CRDT properties verified by tests:
|
||||
|
||||
@@ -160,23 +160,33 @@ export const mergeReplica = (local: ReplicaRow, remote: ReplicaRow): ReplicaRow
|
||||
const fields = mergeFields(local.fields_jsonb, remote.fields_jsonb);
|
||||
const deleted_at_ts = hlcMax(local.deleted_at_ts, remote.deleted_at_ts);
|
||||
|
||||
let reincarnation: string | null;
|
||||
const localReinc = local.reincarnation;
|
||||
const remoteReinc = remote.reincarnation;
|
||||
if (localReinc === remoteReinc) {
|
||||
reincarnation = localReinc;
|
||||
} else {
|
||||
const cmp = hlcCompare(local.updated_at_ts, remote.updated_at_ts);
|
||||
reincarnation = cmp >= 0 ? localReinc : remoteReinc;
|
||||
}
|
||||
const reincarnationCandidates = [
|
||||
local.reincarnation ? { token: local.reincarnation, t: local.updated_at_ts } : null,
|
||||
remote.reincarnation ? { token: remote.reincarnation, t: remote.updated_at_ts } : null,
|
||||
].filter((c): c is { token: string; t: Hlc } => c !== null);
|
||||
const winningReincarnation =
|
||||
reincarnationCandidates.length === 0
|
||||
? null
|
||||
: reincarnationCandidates.reduce((a, b) => (hlcCompare(a.t, b.t) >= 0 ? a : b));
|
||||
const reincarnation =
|
||||
winningReincarnation &&
|
||||
(!deleted_at_ts || hlcCompare(winningReincarnation.t, deleted_at_ts) > 0)
|
||||
? winningReincarnation.token
|
||||
: null;
|
||||
|
||||
const manifest_jsonb =
|
||||
hlcCompare(remote.updated_at_ts, local.updated_at_ts) > 0
|
||||
? remote.manifest_jsonb
|
||||
: local.manifest_jsonb;
|
||||
remote.manifest_jsonb === null
|
||||
? local.manifest_jsonb
|
||||
: local.manifest_jsonb === null
|
||||
? remote.manifest_jsonb
|
||||
: hlcCompare(remote.updated_at_ts, local.updated_at_ts) > 0
|
||||
? remote.manifest_jsonb
|
||||
: local.manifest_jsonb;
|
||||
|
||||
const schema_version = Math.max(local.schema_version, remote.schema_version);
|
||||
const updated_at_ts = computeUpdatedAt(fields, deleted_at_ts);
|
||||
const contentUpdatedAt = computeUpdatedAt(fields, deleted_at_ts);
|
||||
const rowUpdatedAt = hlcMax(local.updated_at_ts, remote.updated_at_ts);
|
||||
const updated_at_ts = hlcMax(contentUpdatedAt, rowUpdatedAt) ?? contentUpdatedAt;
|
||||
|
||||
return {
|
||||
user_id: local.user_id,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { hlcCompare } from './crdt';
|
||||
import type { ReplicaRow } from '@/types/replica';
|
||||
|
||||
/**
|
||||
* Interpret remove-wins + reincarnation semantics for a pulled row.
|
||||
*
|
||||
* Per the plan: tombstones never disappear at the merge level. A row is
|
||||
* "alive" if it was never deleted, OR if a reincarnation token was minted
|
||||
* AFTER the tombstone. The specific check uses HLC ordering — `>=` rather
|
||||
* than `>` so that a same-HLC reincarnation (mid-tick edge) reads as alive.
|
||||
*
|
||||
* Used by the pull-side orchestrator to decide whether to surface a row
|
||||
* to the local store as a live entry or as a tombstone to soft-delete.
|
||||
*/
|
||||
export const isReplicaRowAlive = (row: ReplicaRow): boolean => {
|
||||
if (!row.deleted_at_ts) return true;
|
||||
if (!row.reincarnation) return false;
|
||||
return hlcCompare(row.updated_at_ts, row.deleted_at_ts) >= 0;
|
||||
};
|
||||
@@ -300,9 +300,16 @@ export abstract class BaseAppService implements AppService {
|
||||
kind: string,
|
||||
replicaId: string,
|
||||
filename: string,
|
||||
dst: string,
|
||||
lfp: string,
|
||||
base: BaseDir,
|
||||
onProgress?: ProgressHandler,
|
||||
) {
|
||||
// Resolve the relative `<bundleDir>/<filename>` lfp against the
|
||||
// replica's base dir before downloading. Mirrors how upload uses
|
||||
// `resolveFilePath(opts.lfp, opts.base)`. Without this, the writer
|
||||
// lands the bytes at the literal lfp (no base prefix) so subsequent
|
||||
// openFile(lfp, base) calls fail with "File not found".
|
||||
const dst = await this.resolveFilePath(lfp, base);
|
||||
return CloudSvc.downloadReplicaFileFromCloud(this, {
|
||||
kind,
|
||||
replicaId,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ImportedDictionary } from './types';
|
||||
|
||||
/**
|
||||
* Resolve which existing (non-deleted) entries the incoming bundle should
|
||||
* replace. Match by contentId first (stable per file content; survives
|
||||
* user-driven renames), fall back to name when either side lacks contentId
|
||||
* (legacy bundles imported before the contentId field existed).
|
||||
*
|
||||
* Returns all matching existing entries — multiple are possible when the
|
||||
* user previously imported the same file more than once.
|
||||
*/
|
||||
export const findExistingDictionaryMatches = (
|
||||
incoming: ImportedDictionary,
|
||||
existing: ImportedDictionary[],
|
||||
): ImportedDictionary[] => {
|
||||
const live = existing.filter((d) => !d.deletedAt);
|
||||
|
||||
if (incoming.contentId) {
|
||||
const byContent = live.filter((d) => d.contentId === incoming.contentId);
|
||||
if (byContent.length > 0) return byContent;
|
||||
// contentId is set on the incoming side but no existing entry has it.
|
||||
// Fall through to name match for legacy entries (contentId-less) that
|
||||
// could correspond to the same file under a previous import.
|
||||
}
|
||||
|
||||
return live.filter((d) => !d.contentId && d.name === incoming.name);
|
||||
};
|
||||
|
||||
/**
|
||||
* When re-importing a file that matches an existing entry, keep the user's
|
||||
* label. The match was by content (or by legacy name), so the parsed name
|
||||
* on `incoming` is whatever the bundle's metadata says — but the user may
|
||||
* have customized the label since their original import. Preserving their
|
||||
* choice avoids surprising them when re-import is conceptually a content
|
||||
* refresh, not a label reset.
|
||||
*
|
||||
* If `matches` is empty, returns `incoming` (with a fresh object identity).
|
||||
* If multiple matches exist (the user previously imported the same file
|
||||
* more than once and now we're collapsing them), the first match's name
|
||||
* wins — arbitrary but deterministic.
|
||||
*/
|
||||
export const preserveUserCustomName = (
|
||||
incoming: ImportedDictionary,
|
||||
matches: ImportedDictionary[],
|
||||
): ImportedDictionary => {
|
||||
if (matches.length === 0) return { ...incoming };
|
||||
return { ...incoming, name: matches[0]!.name };
|
||||
};
|
||||
|
||||
/**
|
||||
* Preserve durable local state when a live dictionary is re-imported.
|
||||
*
|
||||
* The fresh import owns parsed/file-backed fields (`id`, `bundleDir`,
|
||||
* `files`, `contentId`, `kind`, `lang`, unsupported status). The existing
|
||||
* live entry owns user/local continuity fields: display name, original
|
||||
* import time, and any sticky reincarnation token.
|
||||
*/
|
||||
export const preserveLiveDictionaryState = (
|
||||
incoming: ImportedDictionary,
|
||||
matches: ImportedDictionary[],
|
||||
): ImportedDictionary => {
|
||||
if (matches.length === 0) return { ...incoming };
|
||||
const first = matches[0]!;
|
||||
return {
|
||||
...incoming,
|
||||
name: first.name,
|
||||
addedAt: first.addedAt,
|
||||
...(first.reincarnation ? { reincarnation: first.reincarnation } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Explicitly re-importing the same content is enough user intent to mint
|
||||
* a revival token when the local live entry does not already have one.
|
||||
*
|
||||
* This covers stale-local cases: another device may have tombstoned the
|
||||
* server row, while this device still has a live cache entry. The live
|
||||
* replacement path would otherwise publish `reincarnation = null`, and
|
||||
* remove-wins would keep the remote row hidden forever.
|
||||
*/
|
||||
export const shouldMintReincarnationForLiveReimport = (
|
||||
incoming: ImportedDictionary,
|
||||
matches: ImportedDictionary[],
|
||||
): boolean => {
|
||||
if (!incoming.contentId || matches.length === 0) return false;
|
||||
const first = matches[0]!;
|
||||
return first.contentId === incoming.contentId && !first.reincarnation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find soft-deleted (tombstoned) existing entries that share the
|
||||
* incoming bundle's contentId. Used by the importer to detect
|
||||
* re-import-after-delete and mint a reincarnation token. Returns []
|
||||
* if the incoming dict has no contentId (legacy bundles can't
|
||||
* reincarnate; they get a fresh import path).
|
||||
*/
|
||||
export const findTombstonedDictionaryMatches = (
|
||||
incoming: ImportedDictionary,
|
||||
existing: ImportedDictionary[],
|
||||
): ImportedDictionary[] => {
|
||||
if (!incoming.contentId) return [];
|
||||
return existing.filter((d) => d.deletedAt && d.contentId && d.contentId === incoming.contentId);
|
||||
};
|
||||
@@ -16,6 +16,14 @@ import { getFilename } from '@/utils/path';
|
||||
import type { ImportedDictionary } from './types';
|
||||
import { scanEntryOffsets, serializeOffsetsSidecar } from './stardictReader';
|
||||
import { computeDictionaryContentId } from './contentId';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
findExistingDictionaryMatches,
|
||||
findTombstonedDictionaryMatches,
|
||||
preserveLiveDictionaryState,
|
||||
preserveUserCustomName,
|
||||
shouldMintReincarnationForLiveReimport,
|
||||
} from './dictionaryDedup';
|
||||
|
||||
/** GZIP magic bytes — used to detect DictZip-compressed `.dict` files. */
|
||||
const GZIP_MAGIC = [0x1f, 0x8b, 0x08];
|
||||
@@ -526,23 +534,19 @@ export async function importDictionaries(
|
||||
existingDictionaries: ImportedDictionary[] = [],
|
||||
): Promise<ImportDictionariesResult> {
|
||||
const { bundles, orphans } = groupBundlesByStem(files);
|
||||
// Index existing entries by name. Multiple entries can share a name when
|
||||
// the user previously imported the same dict more than once — we replace
|
||||
// them all with the single new bundle.
|
||||
const existingByName = new Map<string, ImportedDictionary[]>();
|
||||
for (const d of existingDictionaries) {
|
||||
if (d.deletedAt) continue;
|
||||
const list = existingByName.get(d.name);
|
||||
if (list) list.push(d);
|
||||
else existingByName.set(d.name, [d]);
|
||||
}
|
||||
// Track all existing entries; findExistingDictionaryMatches handles the
|
||||
// contentId-vs-name tier logic. Re-importing a renamed dict still matches
|
||||
// because contentId is stable per file content.
|
||||
const existing: ImportedDictionary[] = [...existingDictionaries];
|
||||
|
||||
const imported: ImportedDictionary[] = [];
|
||||
const replacements: { oldIds: string[]; newDict: ImportedDictionary }[] = [];
|
||||
// Names already added during this single import call. A second bundle in
|
||||
// the same selection that shares a name with an earlier one is dropped
|
||||
// (the first wins) so we don't end up with intra-call duplicates.
|
||||
const seenNames = new Set<string>();
|
||||
// ContentIds (or, for legacy bundles without one, names) already added in
|
||||
// this import call. A second bundle in the same selection that matches an
|
||||
// earlier one is dropped (the first wins) so we don't end up with
|
||||
// intra-call duplicates.
|
||||
const seenContentIds = new Set<string>();
|
||||
const seenLegacyNames = new Set<string>();
|
||||
|
||||
for (const bundle of bundles) {
|
||||
let dict: ImportedDictionary;
|
||||
@@ -556,7 +560,11 @@ export async function importDictionaries(
|
||||
dict = await importSlobBundle(fs, bundle);
|
||||
}
|
||||
|
||||
if (seenNames.has(dict.name)) {
|
||||
const intraCallKey = dict.contentId ?? `__name:${dict.name}`;
|
||||
const isIntraCallDup = dict.contentId
|
||||
? seenContentIds.has(dict.contentId)
|
||||
: seenLegacyNames.has(dict.name);
|
||||
if (isIntraCallDup) {
|
||||
try {
|
||||
await fs.removeDir(dict.bundleDir, 'Dictionaries', true);
|
||||
} catch (err) {
|
||||
@@ -564,10 +572,12 @@ export async function importDictionaries(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
seenNames.add(dict.name);
|
||||
if (dict.contentId) seenContentIds.add(dict.contentId);
|
||||
else seenLegacyNames.add(dict.name);
|
||||
void intraCallKey;
|
||||
|
||||
const olds = existingByName.get(dict.name);
|
||||
if (olds && olds.length > 0) {
|
||||
const olds = findExistingDictionaryMatches(dict, existing);
|
||||
if (olds.length > 0) {
|
||||
for (const old of olds) {
|
||||
try {
|
||||
await fs.removeDir(old.bundleDir, 'Dictionaries', true);
|
||||
@@ -575,10 +585,41 @@ export async function importDictionaries(
|
||||
console.warn('Failed to remove replaced bundle dir', old.bundleDir, err);
|
||||
}
|
||||
}
|
||||
replacements.push({ oldIds: olds.map((o) => o.id), newDict: dict });
|
||||
} else {
|
||||
imported.push(dict);
|
||||
// Drop matched entries from `existing` so subsequent bundles in this
|
||||
// call don't double-replace them.
|
||||
const oldIdSet = new Set(olds.map((o) => o.id));
|
||||
for (let i = existing.length - 1; i >= 0; i--) {
|
||||
if (oldIdSet.has(existing[i]!.id)) existing.splice(i, 1);
|
||||
}
|
||||
// Preserve durable live-entry state across re-import while keeping
|
||||
// parsed/file-backed fields from the fresh bundle.
|
||||
const preserved = preserveLiveDictionaryState(dict, olds);
|
||||
const newDict = shouldMintReincarnationForLiveReimport(dict, olds)
|
||||
? { ...preserved, reincarnation: uuidv4() }
|
||||
: preserved;
|
||||
replacements.push({ oldIds: olds.map((o) => o.id), newDict });
|
||||
continue;
|
||||
}
|
||||
|
||||
// No live match — but check for a tombstoned (soft-deleted) entry
|
||||
// with the same contentId. If found, this is a reincarnation: mint
|
||||
// a fresh token so the server-side row surfaces as alive again on
|
||||
// every device that pulls.
|
||||
const tombstoned = findTombstonedDictionaryMatches(dict, existing);
|
||||
if (tombstoned.length > 0) {
|
||||
const tombstonedIdSet = new Set(tombstoned.map((o) => o.id));
|
||||
for (let i = existing.length - 1; i >= 0; i--) {
|
||||
if (tombstonedIdSet.has(existing[i]!.id)) existing.splice(i, 1);
|
||||
}
|
||||
const reincarnatedDict = preserveUserCustomName(
|
||||
{ ...dict, reincarnation: uuidv4() },
|
||||
tombstoned,
|
||||
);
|
||||
replacements.push({ oldIds: tombstoned.map((o) => o.id), newDict: reincarnatedDict });
|
||||
continue;
|
||||
}
|
||||
|
||||
imported.push(dict);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -297,7 +297,6 @@ function wireMdxAnchors(
|
||||
e.stopPropagation();
|
||||
onNavigate(target);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,18 @@ export interface ImportedDictionary {
|
||||
* sync wiring treats absent contentId as "needs rehash before sync".
|
||||
*/
|
||||
contentId?: string;
|
||||
/**
|
||||
* Reincarnation token (uuid) minted when the user re-imports a file
|
||||
* whose contentId matches a previously tombstoned replica row. Per
|
||||
* remove-wins semantics, a tombstone never disappears at the merge
|
||||
* level — clients interpret `reincarnation != null` as "alive again"
|
||||
* (the original tombstone stays as history). Set only on the
|
||||
* re-import after a delete. Also minted on explicit same-content live
|
||||
* re-import when the local cache has no token, because another device
|
||||
* may have tombstoned the server row while this device still sees the
|
||||
* entry as live.
|
||||
*/
|
||||
reincarnation?: string;
|
||||
/** Subdirectory under `'Dictionaries'` containing this bundle's files. */
|
||||
bundleDir: string;
|
||||
/** Filenames inside `bundleDir`. The exact set varies by `kind`. */
|
||||
|
||||
@@ -161,6 +161,11 @@ export async function loadSettings(ctx: Context): Promise<SystemSettings> {
|
||||
await saveSettings(ctx.fs, settings);
|
||||
}
|
||||
|
||||
if (!settings.replicaDeviceId) {
|
||||
settings.replicaDeviceId = uuidv4();
|
||||
await saveSettings(ctx.fs, settings);
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { transferManager } from '@/services/transferManager';
|
||||
import { enumerateDictionaryFiles } from './adapters/dictionary';
|
||||
import type { ImportedDictionary } from '@/services/dictionaries/types';
|
||||
import type { AppService } from '@/types/system';
|
||||
import type { ReplicaTransferFile } from '@/store/transferStore';
|
||||
import type { ClosableFile } from '@/utils/file';
|
||||
|
||||
/**
|
||||
* Resolve the on-disk byteSize for one bundle file. Mirrors the
|
||||
* bookService.getBookFileSize pattern: openFile + .size + close.
|
||||
* On Tauri, openFile streams metadata; .size doesn't read the body.
|
||||
*/
|
||||
const resolveByteSize = async (appService: AppService, lfp: string): Promise<number> => {
|
||||
const file = await appService.openFile(lfp, 'Dictionaries');
|
||||
const size = file.size;
|
||||
const closable = file as ClosableFile;
|
||||
if (closable && closable.close) {
|
||||
await closable.close();
|
||||
}
|
||||
return size;
|
||||
};
|
||||
|
||||
/**
|
||||
* Queue a dictionary's binary files for upload via TransferManager.
|
||||
* Reads each file's byteSize once up-front so progress reporting is
|
||||
* accurate, then dispatches to the existing replica-transfer pipeline.
|
||||
*
|
||||
* No-op when:
|
||||
* - the dictionary lacks contentId (legacy bundle, needs rehash)
|
||||
* - TransferManager isn't initialized yet (pre-library mount)
|
||||
*
|
||||
* Caller is responsible for ordering this AFTER publishDictionaryUpsert
|
||||
* so the metadata row exists before the manifest commit fires.
|
||||
*/
|
||||
export const queueDictionaryBinaryUpload = async (
|
||||
dict: ImportedDictionary,
|
||||
appService: AppService,
|
||||
): Promise<string | null> => {
|
||||
if (!dict.contentId) return null;
|
||||
if (!transferManager.isReady()) return null;
|
||||
|
||||
const enumerated = enumerateDictionaryFiles(dict);
|
||||
if (enumerated.length === 0) return null;
|
||||
|
||||
const files: ReplicaTransferFile[] = await Promise.all(
|
||||
enumerated.map(async (f) => ({
|
||||
logical: f.logical,
|
||||
lfp: f.lfp,
|
||||
byteSize: await resolveByteSize(appService, f.lfp),
|
||||
})),
|
||||
);
|
||||
|
||||
return transferManager.queueReplicaUpload(
|
||||
'dictionary',
|
||||
dict.contentId,
|
||||
dict.name,
|
||||
files,
|
||||
'Dictionaries',
|
||||
{ reincarnation: dict.reincarnation },
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AppService } from '@/types/system';
|
||||
import type { Hlc } from '@/types/replica';
|
||||
import type { CursorStore } from './replicaSyncManager';
|
||||
|
||||
export interface SettingsCursorStoreOpts {
|
||||
/** Debounce window for the load-merge-save flush. Defaults to 1s. */
|
||||
debounceMs?: number;
|
||||
/** Test seam — defaults to the platform setTimeout. */
|
||||
setTimeoutFn?: typeof setTimeout;
|
||||
/** Test seam. */
|
||||
clearTimeoutFn?: typeof clearTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Production CursorStore backed by appService.loadSettings + saveSettings.
|
||||
*
|
||||
* Cursors are cached in memory so get() is sync. set() debounces a save
|
||||
* that does a fresh load-merge-save round-trip — this keeps us from
|
||||
* clobbering fields written elsewhere in the same session (e.g., the
|
||||
* library page's setSettings).
|
||||
*
|
||||
* Cursor advance is best-effort: a lost save just means the next pull
|
||||
* re-fetches a few rows. We never block the sync flow on disk IO.
|
||||
*/
|
||||
export const createSettingsCursorStore = (
|
||||
appService: AppService,
|
||||
opts: SettingsCursorStoreOpts = {},
|
||||
): CursorStore => {
|
||||
const cache = new Map<string, Hlc>();
|
||||
const setTimeoutFn = opts.setTimeoutFn ?? setTimeout;
|
||||
const clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;
|
||||
const debounceMs = opts.debounceMs ?? 1000;
|
||||
let pending: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const settings = await appService.loadSettings();
|
||||
for (const [k, v] of Object.entries(settings.lastSyncedAtReplicas ?? {})) {
|
||||
cache.set(k, v as Hlc);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('replica cursor hydrate failed', err);
|
||||
}
|
||||
})();
|
||||
|
||||
const flush = async () => {
|
||||
try {
|
||||
const settings = await appService.loadSettings();
|
||||
settings.lastSyncedAtReplicas = Object.fromEntries(cache);
|
||||
await appService.saveSettings(settings);
|
||||
} catch (err) {
|
||||
console.warn('replica cursor save failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (pending !== null) clearTimeoutFn(pending);
|
||||
pending = setTimeoutFn(() => {
|
||||
pending = null;
|
||||
void flush();
|
||||
}, debounceMs);
|
||||
};
|
||||
|
||||
return {
|
||||
get: (kind) => cache.get(kind) ?? null,
|
||||
set: (kind, hlc) => {
|
||||
cache.set(kind, hlc);
|
||||
scheduleFlush();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { FieldEnvelope, FieldsObject, Manifest, ReplicaRow } from '@/types/replica';
|
||||
import type { ImportedDictionary } from '@/services/dictionaries/types';
|
||||
|
||||
export interface UnwrappedDictionaryFields {
|
||||
name?: string;
|
||||
kind?: ImportedDictionary['kind'];
|
||||
lang?: string;
|
||||
addedAt?: number;
|
||||
unsupported?: boolean;
|
||||
unsupportedReason?: string;
|
||||
}
|
||||
|
||||
const unwrapEnvelopeValue = (env: FieldEnvelope | undefined): unknown =>
|
||||
env && typeof env === 'object' && 'v' in env ? (env as FieldEnvelope).v : undefined;
|
||||
|
||||
/**
|
||||
* Unpack a fields_jsonb (envelope-wrapped) object into plain values for
|
||||
* the dictionary kind. Mirrors dictionaryAdapter.unpack but takes the
|
||||
* raw envelope shape so it can also tolerate cipher envelopes (no
|
||||
* encrypted fields on dictionary today, but the helper is defensive).
|
||||
*/
|
||||
export const unwrapDictionaryFields = (fields: FieldsObject): UnwrappedDictionaryFields => {
|
||||
const name = unwrapEnvelopeValue(fields['name']);
|
||||
const kind = unwrapEnvelopeValue(fields['kind']);
|
||||
const lang = unwrapEnvelopeValue(fields['lang']);
|
||||
const addedAt = unwrapEnvelopeValue(fields['addedAt']);
|
||||
const unsupported = unwrapEnvelopeValue(fields['unsupported']);
|
||||
const unsupportedReason = unwrapEnvelopeValue(fields['unsupportedReason']);
|
||||
|
||||
return {
|
||||
name: typeof name === 'string' ? name : undefined,
|
||||
kind:
|
||||
kind === 'mdict' || kind === 'stardict' || kind === 'dict' || kind === 'slob'
|
||||
? kind
|
||||
: undefined,
|
||||
lang: typeof lang === 'string' ? lang : undefined,
|
||||
addedAt: typeof addedAt === 'number' ? addedAt : undefined,
|
||||
unsupported: unsupported === true ? true : undefined,
|
||||
unsupportedReason: typeof unsupportedReason === 'string' ? unsupportedReason : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconstruct ImportedDictionary.files from a manifest. Dispatch by
|
||||
* filename extension within the bundle's declared kind. Skips
|
||||
* `.idx.offsets` / `.syn.offsets` sidecars — those are device-local
|
||||
* indices that don't belong in the synced manifest.
|
||||
*/
|
||||
export const filesFromManifest = (
|
||||
manifest: Manifest | null,
|
||||
kind: ImportedDictionary['kind'],
|
||||
): ImportedDictionary['files'] => {
|
||||
const out: ImportedDictionary['files'] = {};
|
||||
if (!manifest) return out;
|
||||
|
||||
const mdd: string[] = [];
|
||||
const css: string[] = [];
|
||||
|
||||
for (const f of manifest.files) {
|
||||
const lower = f.filename.toLowerCase();
|
||||
if (lower.endsWith('.idx.offsets') || lower.endsWith('.syn.offsets')) continue;
|
||||
|
||||
if (kind === 'mdict') {
|
||||
if (lower.endsWith('.mdx')) out.mdx = f.filename;
|
||||
else if (lower.endsWith('.mdd')) mdd.push(f.filename);
|
||||
else if (lower.endsWith('.css')) css.push(f.filename);
|
||||
} else if (kind === 'stardict') {
|
||||
if (lower.endsWith('.ifo')) out.ifo = f.filename;
|
||||
else if (lower.endsWith('.idx')) out.idx = f.filename;
|
||||
else if (lower.endsWith('.syn')) out.syn = f.filename;
|
||||
else if (lower.endsWith('.dict.dz') || lower.endsWith('.dict')) out.dict = f.filename;
|
||||
} else if (kind === 'dict') {
|
||||
if (lower.endsWith('.index')) out.index = f.filename;
|
||||
else if (lower.endsWith('.dict.dz') || lower.endsWith('.dict')) out.dict = f.filename;
|
||||
} else if (kind === 'slob') {
|
||||
if (lower.endsWith('.slob')) out.slob = f.filename;
|
||||
}
|
||||
}
|
||||
|
||||
if (mdd.length > 0) out.mdd = mdd;
|
||||
if (css.length > 0) out.css = css;
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a local ImportedDictionary placeholder from a pulled replica row.
|
||||
* The placeholder is marked `unavailable: true` until the binary
|
||||
* download completes — at which point a download-completion handler
|
||||
* clears the flag.
|
||||
*
|
||||
* Returns null when the row's fields_jsonb is malformed (missing
|
||||
* required name or kind). The pull orchestrator skips null returns.
|
||||
*/
|
||||
export const buildLocalDictFromRow = (
|
||||
row: ReplicaRow,
|
||||
bundleDir: string,
|
||||
): ImportedDictionary | null => {
|
||||
const fields = unwrapDictionaryFields(row.fields_jsonb);
|
||||
if (!fields.name || !fields.kind) return null;
|
||||
|
||||
const dict: ImportedDictionary = {
|
||||
id: bundleDir,
|
||||
contentId: row.replica_id,
|
||||
kind: fields.kind,
|
||||
name: fields.name,
|
||||
bundleDir,
|
||||
files: filesFromManifest(row.manifest_jsonb, fields.kind),
|
||||
addedAt: fields.addedAt ?? Date.now(),
|
||||
unavailable: true,
|
||||
};
|
||||
if (fields.lang !== undefined) dict.lang = fields.lang;
|
||||
if (fields.unsupported) dict.unsupported = true;
|
||||
if (fields.unsupportedReason) dict.unsupportedReason = fields.unsupportedReason;
|
||||
if (row.reincarnation) dict.reincarnation = row.reincarnation;
|
||||
|
||||
return dict;
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { setField, removeReplica, hlcMax } from '@/libs/crdt';
|
||||
import { getUserID } from '@/utils/access';
|
||||
import { getReplicaSync } from './replicaSync';
|
||||
import {
|
||||
DICTIONARY_KIND,
|
||||
DICTIONARY_SCHEMA_VERSION,
|
||||
dictionaryAdapter,
|
||||
} from './adapters/dictionary';
|
||||
import type { ImportedDictionary } from '@/services/dictionaries/types';
|
||||
import type { FieldsObject, Hlc, ReplicaRow } from '@/types/replica';
|
||||
|
||||
/**
|
||||
* Build + push a CRDT row for an upsert of a single dictionary. Each
|
||||
* field gets a fresh HLC stamp; updated_at_ts is the max of all field
|
||||
* stamps. The row is queued via replicaSyncManager.markDirty (5s
|
||||
* debounced push, immediate flush on visibilitychange / online).
|
||||
*
|
||||
* No-op when:
|
||||
* - replica sync is not initialized (e.g., user signed out)
|
||||
* - the dictionary lacks contentId (legacy bundle, needs rehash before sync)
|
||||
* - the user is not authenticated
|
||||
*/
|
||||
export const publishDictionaryUpsert = async (dict: ImportedDictionary): Promise<void> => {
|
||||
const ctx = getReplicaSync();
|
||||
if (!ctx) return;
|
||||
if (!dict.contentId) return;
|
||||
const userId = await getUserID();
|
||||
if (!userId) return;
|
||||
|
||||
const packed = dictionaryAdapter.pack(dict);
|
||||
let fields: FieldsObject = {};
|
||||
let maxFieldHlc: Hlc | null = null;
|
||||
for (const [key, value] of Object.entries(packed)) {
|
||||
const t = ctx.hlc.next();
|
||||
fields = setField(fields, key, value, t, ctx.deviceId);
|
||||
maxFieldHlc = hlcMax(maxFieldHlc, t);
|
||||
}
|
||||
|
||||
const updatedAt = maxFieldHlc ?? ctx.hlc.next();
|
||||
|
||||
const row: ReplicaRow = {
|
||||
user_id: userId,
|
||||
kind: DICTIONARY_KIND,
|
||||
replica_id: dict.contentId,
|
||||
fields_jsonb: fields,
|
||||
manifest_jsonb: null,
|
||||
deleted_at_ts: null,
|
||||
reincarnation: dict.reincarnation ?? null,
|
||||
updated_at_ts: updatedAt,
|
||||
schema_version: DICTIONARY_SCHEMA_VERSION,
|
||||
};
|
||||
ctx.manager.markDirty(row);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tombstone a dictionary row by contentId. The row carries no fields —
|
||||
* just the deleted_at_ts HLC. Per remove-wins semantics, a later field
|
||||
* write does NOT revive this row; only an explicit reincarnation token
|
||||
* does.
|
||||
*
|
||||
* No-op when replica sync isn't initialized or the user isn't authenticated.
|
||||
*/
|
||||
export const publishDictionaryDelete = async (contentId: string): Promise<void> => {
|
||||
const ctx = getReplicaSync();
|
||||
if (!ctx) return;
|
||||
const userId = await getUserID();
|
||||
if (!userId) return;
|
||||
|
||||
const tombstoneHlc = ctx.hlc.next();
|
||||
const baseRow: ReplicaRow = {
|
||||
user_id: userId,
|
||||
kind: DICTIONARY_KIND,
|
||||
replica_id: contentId,
|
||||
fields_jsonb: {},
|
||||
manifest_jsonb: null,
|
||||
deleted_at_ts: null,
|
||||
reincarnation: null,
|
||||
updated_at_ts: tombstoneHlc,
|
||||
schema_version: DICTIONARY_SCHEMA_VERSION,
|
||||
};
|
||||
ctx.manager.markDirty(removeReplica(baseRow, tombstoneHlc));
|
||||
};
|
||||
|
||||
/**
|
||||
* Publish a manifest for an existing dictionary row. Called once binary
|
||||
* uploads complete (transferManager fires `replica-transfer-complete`).
|
||||
* The fields_jsonb is empty — server-side per-field LWW preserves the
|
||||
* existing fields; only manifest_jsonb is updated. updated_at_ts = fresh
|
||||
* HLC so the manifest wins over any prior null value on the row.
|
||||
*
|
||||
* No-ops when sync isn't initialized or the user isn't authenticated.
|
||||
*/
|
||||
export const publishDictionaryManifest = async (
|
||||
contentId: string,
|
||||
files: { filename: string; byteSize: number; partialMd5: string }[],
|
||||
reincarnation?: string,
|
||||
): Promise<void> => {
|
||||
const ctx = getReplicaSync();
|
||||
if (!ctx) return;
|
||||
const userId = await getUserID();
|
||||
if (!userId) return;
|
||||
|
||||
const updatedAt = ctx.hlc.next();
|
||||
const row: ReplicaRow = {
|
||||
user_id: userId,
|
||||
kind: DICTIONARY_KIND,
|
||||
replica_id: contentId,
|
||||
fields_jsonb: {},
|
||||
manifest_jsonb: { files, schemaVersion: 1 },
|
||||
deleted_at_ts: null,
|
||||
reincarnation: reincarnation ?? null,
|
||||
updated_at_ts: updatedAt,
|
||||
schema_version: DICTIONARY_SCHEMA_VERSION,
|
||||
};
|
||||
ctx.manager.markDirty(row);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { isReplicaRowAlive } from '@/libs/replicaInterpret';
|
||||
import type { ReplicaRow } from '@/types/replica';
|
||||
import type { ImportedDictionary } from '@/services/dictionaries/types';
|
||||
import type { ReplicaTransferFile } from '@/store/transferStore';
|
||||
import type { BaseDir } from '@/types/system';
|
||||
import { buildLocalDictFromRow } from './replicaDictionaryApply';
|
||||
|
||||
export interface PullDictionariesDeps {
|
||||
/** Pulls dictionary rows since the last cursor advance. */
|
||||
pull(): Promise<ReplicaRow[]>;
|
||||
/** Looks up an existing local dict by its cross-device contentId. */
|
||||
findByContentId(contentId: string): ImportedDictionary | undefined;
|
||||
/** Adds a remote-sourced dict to the local store WITHOUT republishing. */
|
||||
applyRemoteDictionary(dict: ImportedDictionary): void;
|
||||
/**
|
||||
* Tombstones the local entry whose contentId matches. Implementer
|
||||
* looks up by contentId, calls removeDictionary on the local store,
|
||||
* but skips publishDictionaryDelete (the row is already tombstoned
|
||||
* server-side; we just observed that fact).
|
||||
*/
|
||||
softDeleteByContentId(contentId: string): void;
|
||||
/**
|
||||
* Mints a fresh local bundleDir, creates the directory on disk under
|
||||
* the 'Dictionaries' base dir, returns the directory name (relative).
|
||||
*/
|
||||
createBundleDir(): Promise<string>;
|
||||
/**
|
||||
* Hands the manifest's binary files off to TransferManager for
|
||||
* download. Returns the transfer id (or null if the queue isn't
|
||||
* ready). Caller arguments mirror transferManager.queueReplicaDownload.
|
||||
*/
|
||||
queueReplicaDownload(
|
||||
contentId: string,
|
||||
displayTitle: string,
|
||||
files: ReplicaTransferFile[],
|
||||
bundleDir: string,
|
||||
base: BaseDir,
|
||||
): string | null;
|
||||
/**
|
||||
* Returns true iff EVERY filename exists on disk under
|
||||
* `<bundleDir>/<filename>` in the kind's base dir. Lets the
|
||||
* orchestrator skip the download queue when the binaries from a
|
||||
* previous session are still around — refreshing the page or pulling
|
||||
* a row whose contentId already maps to a populated bundle is a
|
||||
* no-op rather than a re-download.
|
||||
*/
|
||||
filesExist(bundleDir: string, filenames: string[]): Promise<boolean>;
|
||||
/**
|
||||
* Hydrates the persistence-aware local store from disk before the
|
||||
* orchestrator queries findByContentId. Without this, applyRow's
|
||||
* subsequent applyRemoteDictionary + auto-persist round-trip
|
||||
* overwrites settings.customDictionaries with only the rows we just
|
||||
* applied — wiping persisted entries that hadn't yet been pulled
|
||||
* into the zustand store by a feature mount.
|
||||
*/
|
||||
hydrateLocalStore?(): Promise<void>;
|
||||
/**
|
||||
* Optional auth precheck. When provided and resolves to false, the
|
||||
* orchestrator skips the entire pull (no network call, no warnings).
|
||||
* Lets the boot site avoid spamming "Not authenticated" errors when
|
||||
* the user is signed out but a prior session left a deviceId behind.
|
||||
*/
|
||||
isAuthenticated?(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const MANIFEST_FILE_TO_TRANSFER = (
|
||||
filename: string,
|
||||
byteSize: number,
|
||||
bundleDir: string,
|
||||
): ReplicaTransferFile => ({
|
||||
logical: filename,
|
||||
// Local file path under the 'Dictionaries' base — TransferManager
|
||||
// resolves it via appService for the actual download IO.
|
||||
lfp: `${bundleDir}/${filename}`,
|
||||
byteSize,
|
||||
});
|
||||
|
||||
const applyRow = async (row: ReplicaRow, deps: PullDictionariesDeps): Promise<void> => {
|
||||
const local = deps.findByContentId(row.replica_id);
|
||||
const alive = isReplicaRowAlive(row);
|
||||
|
||||
if (!alive) {
|
||||
if (local && !local.deletedAt) {
|
||||
deps.softDeleteByContentId(row.replica_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Decide bundleDir + display name. If a local entry already maps this
|
||||
// contentId, reuse its bundleDir so we don't orphan the previously
|
||||
// downloaded binaries; otherwise mint a fresh dir and apply the remote
|
||||
// dict to the local store.
|
||||
let bundleDir: string;
|
||||
let displayName: string;
|
||||
if (local) {
|
||||
bundleDir = local.bundleDir;
|
||||
displayName = local.name;
|
||||
} else {
|
||||
bundleDir = await deps.createBundleDir();
|
||||
const dict = buildLocalDictFromRow(row, bundleDir);
|
||||
if (!dict) return;
|
||||
deps.applyRemoteDictionary(dict);
|
||||
displayName = dict.name;
|
||||
}
|
||||
|
||||
if (!row.manifest_jsonb || row.manifest_jsonb.files.length === 0) return;
|
||||
|
||||
// Skip the download queue if every manifest file is already on disk
|
||||
// under the resolved bundle dir. Refresh-the-page is a no-op rather
|
||||
// than a re-download; partial-download recovery still queues because
|
||||
// some files would be missing.
|
||||
const filenames = row.manifest_jsonb.files.map((f) => f.filename);
|
||||
const allPresent = await deps.filesExist(bundleDir, filenames);
|
||||
if (allPresent) return;
|
||||
|
||||
const files = row.manifest_jsonb.files.map((f) =>
|
||||
MANIFEST_FILE_TO_TRANSFER(f.filename, f.byteSize, bundleDir),
|
||||
);
|
||||
deps.queueReplicaDownload(row.replica_id, displayName, files, bundleDir, 'Dictionaries');
|
||||
};
|
||||
|
||||
/**
|
||||
* Pull-side dispatcher: walks rows since the last cursor advance and
|
||||
* applies each via applyRow. Errors per row are isolated — one bad
|
||||
* row never blocks the others.
|
||||
*/
|
||||
export const pullDictionariesAndApply = async (deps: PullDictionariesDeps): Promise<void> => {
|
||||
if (deps.isAuthenticated && !(await deps.isAuthenticated())) return;
|
||||
// Hydrate the in-memory dict store from disk BEFORE the apply loop.
|
||||
// applyRemoteDictionary auto-persists the in-memory list back to
|
||||
// settings, so if the in-memory list isn't already populated, the
|
||||
// first save would clobber every persisted dict that we hadn't
|
||||
// re-read into memory.
|
||||
if (deps.hydrateLocalStore) {
|
||||
await deps.hydrateLocalStore();
|
||||
}
|
||||
const rows = await deps.pull();
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await applyRow(row, deps);
|
||||
} catch (err) {
|
||||
console.warn('replica pull row apply failed', { replicaId: row.replica_id, err });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HlcGenerator, hlcCompare } from '@/libs/crdt';
|
||||
import { HlcGenerator, hlcCompare, hlcMax, mergeFields } from '@/libs/crdt';
|
||||
import type { Hlc, ReplicaRow } from '@/types/replica';
|
||||
import type { ReplicaSyncClient } from '@/libs/replicaSyncClient';
|
||||
|
||||
@@ -25,6 +25,50 @@ const splitKey = (k: string): DirtyKey => {
|
||||
return { kind: k.slice(0, idx), replicaId: k.slice(idx + 2) };
|
||||
};
|
||||
|
||||
const mergeDirtyRows = (a: ReplicaRow, b: ReplicaRow): ReplicaRow => {
|
||||
if (a.user_id !== b.user_id || a.kind !== b.kind || a.replica_id !== b.replica_id) {
|
||||
throw new Error('mergeDirtyRows: identity mismatch');
|
||||
}
|
||||
|
||||
const fields_jsonb = mergeFields(a.fields_jsonb, b.fields_jsonb);
|
||||
const deleted_at_ts = hlcMax(a.deleted_at_ts, b.deleted_at_ts);
|
||||
|
||||
const reincarnationCandidates = [
|
||||
a.reincarnation ? { token: a.reincarnation, t: a.updated_at_ts } : null,
|
||||
b.reincarnation ? { token: b.reincarnation, t: b.updated_at_ts } : null,
|
||||
].filter((c): c is { token: string; t: Hlc } => c !== null);
|
||||
const winningReincarnation =
|
||||
reincarnationCandidates.length === 0
|
||||
? null
|
||||
: reincarnationCandidates.reduce((x, y) => (hlcCompare(x.t, y.t) >= 0 ? x : y));
|
||||
const reincarnation =
|
||||
winningReincarnation &&
|
||||
(!deleted_at_ts || hlcCompare(winningReincarnation.t, deleted_at_ts) > 0)
|
||||
? winningReincarnation.token
|
||||
: null;
|
||||
|
||||
const manifest_jsonb =
|
||||
b.manifest_jsonb === null
|
||||
? a.manifest_jsonb
|
||||
: a.manifest_jsonb === null
|
||||
? b.manifest_jsonb
|
||||
: hlcCompare(b.updated_at_ts, a.updated_at_ts) > 0
|
||||
? b.manifest_jsonb
|
||||
: a.manifest_jsonb;
|
||||
|
||||
return {
|
||||
user_id: a.user_id,
|
||||
kind: a.kind,
|
||||
replica_id: a.replica_id,
|
||||
fields_jsonb,
|
||||
manifest_jsonb,
|
||||
deleted_at_ts,
|
||||
reincarnation,
|
||||
updated_at_ts: hlcMax(a.updated_at_ts, b.updated_at_ts) ?? a.updated_at_ts,
|
||||
schema_version: Math.max(a.schema_version, b.schema_version),
|
||||
};
|
||||
};
|
||||
|
||||
export class ReplicaSyncManager {
|
||||
private readonly dirty = new Map<string, ReplicaRow>();
|
||||
private readonly debounceMs: number;
|
||||
@@ -44,7 +88,9 @@ export class ReplicaSyncManager {
|
||||
}
|
||||
|
||||
markDirty(row: ReplicaRow): void {
|
||||
this.dirty.set(dirtyKeyOf(row), row);
|
||||
const key = dirtyKeyOf(row);
|
||||
const existing = this.dirty.get(key);
|
||||
this.dirty.set(key, existing ? mergeDirtyRows(existing, row) : row);
|
||||
this.scheduleDebouncedFlush();
|
||||
}
|
||||
|
||||
@@ -77,8 +123,13 @@ export class ReplicaSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
async pull(kind: string): Promise<ReplicaRow[]> {
|
||||
const since = this.opts.cursorStore.get(kind);
|
||||
async pull(kind: string, opts?: { since?: Hlc | null }): Promise<ReplicaRow[]> {
|
||||
// The boot orchestrator passes `{ since: null }` to do a full pull
|
||||
// that ignores the persisted cursor — this lets us recover when a
|
||||
// previous boot advanced the cursor past rows that never made it
|
||||
// into the local store (e.g., apply-without-persist bug). Periodic
|
||||
// sync (visibility / online) keeps using the cursor.
|
||||
const since = opts && 'since' in opts ? (opts.since ?? null) : this.opts.cursorStore.get(kind);
|
||||
const rows = await this.opts.client.pull(kind, since);
|
||||
if (rows.length === 0) return rows;
|
||||
let maxHlc: Hlc = rows[0]!.updated_at_ts;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { partialMD5 } from '@/utils/md5';
|
||||
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
|
||||
import { publishDictionaryManifest } from './replicaPublish';
|
||||
import type { AppService } from '@/types/system';
|
||||
import type { ClosableFile } from '@/utils/file';
|
||||
import type { ReplicaTransferFile } from '@/store/transferStore';
|
||||
|
||||
interface ReplicaTransferCompleteDetail {
|
||||
kind: string;
|
||||
replicaId: string;
|
||||
reincarnation?: string;
|
||||
type: 'upload' | 'download' | 'delete';
|
||||
files?: ReplicaTransferFile[];
|
||||
filenames?: string[];
|
||||
}
|
||||
|
||||
let started = false;
|
||||
let appServiceRef: AppService | null = null;
|
||||
let listener: ((event: CustomEvent) => Promise<void>) | null = null;
|
||||
|
||||
const handleReplicaUpload = async (detail: ReplicaTransferCompleteDetail): Promise<void> => {
|
||||
if (!detail.files || detail.files.length === 0) return;
|
||||
if (!appServiceRef) return;
|
||||
|
||||
try {
|
||||
const manifestFiles = await Promise.all(
|
||||
detail.files.map(async (f) => {
|
||||
const file = await appServiceRef!.openFile(f.lfp, 'Dictionaries');
|
||||
const partialMd5 = await partialMD5(file);
|
||||
const closable = file as ClosableFile;
|
||||
if (closable && closable.close) await closable.close();
|
||||
return { filename: f.logical, byteSize: f.byteSize, partialMd5 };
|
||||
}),
|
||||
);
|
||||
await publishDictionaryManifest(detail.replicaId, manifestFiles, detail.reincarnation);
|
||||
} catch (err) {
|
||||
console.warn('replica-transfer-complete upload handler failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplicaDownload = (detail: ReplicaTransferCompleteDetail): void => {
|
||||
// The pull orchestrator created the local dict with unavailable=true
|
||||
// as a placeholder. Now that the binaries are on disk, clear the flag
|
||||
// so the provider registry surfaces the dict for lookups.
|
||||
try {
|
||||
useCustomDictionaryStore.getState().markAvailableByContentId(detail.replicaId);
|
||||
} catch (err) {
|
||||
console.warn('replica-transfer-complete download handler failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplicaTransferComplete = async (event: CustomEvent): Promise<void> => {
|
||||
const detail = event.detail as ReplicaTransferCompleteDetail | undefined;
|
||||
if (!detail) return;
|
||||
if (detail.kind !== 'dictionary') return;
|
||||
|
||||
if (detail.type === 'upload') {
|
||||
await handleReplicaUpload(detail);
|
||||
} else if (detail.type === 'download') {
|
||||
handleReplicaDownload(detail);
|
||||
}
|
||||
// 'delete' is fire-and-forget; no follow-up needed.
|
||||
};
|
||||
|
||||
/**
|
||||
* Wires the long-lived `replica-transfer-complete` listener that turns
|
||||
* a finished binary upload into a manifest commit (per the upload state
|
||||
* machine: binaries first, manifest LAST). Called once from EnvContext
|
||||
* after appService boots; idempotent.
|
||||
*
|
||||
* Callers that subsequently sign in / out shouldn't re-call this — the
|
||||
* listener doesn't need to know auth state, and publishDictionaryManifest
|
||||
* already gates on the user being authenticated.
|
||||
*/
|
||||
export const startReplicaTransferIntegration = (appService: AppService): void => {
|
||||
appServiceRef = appService;
|
||||
if (started) return;
|
||||
started = true;
|
||||
listener = handleReplicaTransferComplete;
|
||||
eventDispatcher.on('replica-transfer-complete', listener);
|
||||
};
|
||||
|
||||
export const __resetReplicaTransferIntegrationForTests = (): void => {
|
||||
if (listener) {
|
||||
eventDispatcher.off('replica-transfer-complete', listener);
|
||||
listener = null;
|
||||
}
|
||||
started = false;
|
||||
appServiceRef = null;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { useTransferStore, TransferItem, ReplicaTransferFile } from '@/store/tra
|
||||
import { TranslationFunc } from '@/hooks/useTranslation';
|
||||
import { ProgressHandler, ProgressPayload } from '@/utils/transfer';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getTransferMessages } from './transferMessages';
|
||||
|
||||
const TRANSFER_QUEUE_KEY = 'readest_transfer_queue';
|
||||
const RETRY_DELAY_BASE_MS = 2000;
|
||||
@@ -22,6 +23,10 @@ class TransferManager {
|
||||
private getLibrary: (() => Book[]) | null = null;
|
||||
private updateBook: ((book: Book) => Promise<void>) | null = null;
|
||||
private _: TranslationFunc | null = null;
|
||||
private readyResolve: () => void = () => {};
|
||||
private readyPromise: Promise<void> = new Promise<void>((resolve) => {
|
||||
this.readyResolve = resolve;
|
||||
});
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -46,6 +51,7 @@ class TransferManager {
|
||||
this._ = translationFn;
|
||||
await this.loadPersistedQueue();
|
||||
this.isInitialized = true;
|
||||
this.readyResolve();
|
||||
|
||||
// Start processing queue
|
||||
this.processQueue();
|
||||
@@ -55,6 +61,16 @@ class TransferManager {
|
||||
return this.isInitialized && this.appService !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once `initialize()` has completed. Lets callers that need
|
||||
* to enqueue transfers (e.g., the boot-time replica pull) defer until
|
||||
* the manager is wired up — the manager only inits after the library
|
||||
* is loaded, which can lag well behind app boot.
|
||||
*/
|
||||
waitUntilReady(): Promise<void> {
|
||||
return this.readyPromise;
|
||||
}
|
||||
|
||||
queueUpload(book: Book, priority: number = 10): string | null {
|
||||
if (!this.isReady()) {
|
||||
console.warn('TransferManager not initialized');
|
||||
@@ -125,7 +141,7 @@ class TransferManager {
|
||||
displayTitle: string,
|
||||
files: ReplicaTransferFile[],
|
||||
base: BaseDir,
|
||||
opts: { priority?: number; isBackground?: boolean } = {},
|
||||
opts: { priority?: number; isBackground?: boolean; reincarnation?: string } = {},
|
||||
): string | null {
|
||||
if (!this.isReady()) {
|
||||
console.warn('TransferManager not initialized');
|
||||
@@ -140,6 +156,7 @@ class TransferManager {
|
||||
isBackground: opts.isBackground,
|
||||
files,
|
||||
base,
|
||||
reincarnation: opts.reincarnation,
|
||||
});
|
||||
this.persistQueue();
|
||||
this.processQueue();
|
||||
@@ -317,17 +334,13 @@ class TransferManager {
|
||||
|
||||
useTransferStore.getState().setTransferStatus(transfer.id, 'completed');
|
||||
|
||||
const successMessages = {
|
||||
upload: _('Book uploaded: {{title}}', { title: transfer.bookTitle }),
|
||||
download: _('Book downloaded: {{title}}', { title: transfer.bookTitle }),
|
||||
delete: _('Deleted cloud backup of the book: {{title}}', { title: transfer.bookTitle }),
|
||||
};
|
||||
const messages = getTransferMessages(transfer, _);
|
||||
|
||||
if (!transfer.isBackground) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
timeout: 2000,
|
||||
message: successMessages[transfer.type],
|
||||
message: messages.success[transfer.type],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -365,13 +378,7 @@ class TransferManager {
|
||||
message: _('Insufficient storage quota'),
|
||||
});
|
||||
} else {
|
||||
const errorMessages = {
|
||||
upload: _('Failed to upload book: {{title}}', { title: transfer.bookTitle }),
|
||||
download: _('Failed to download book: {{title}}', { title: transfer.bookTitle }),
|
||||
delete: _('Failed to delete cloud backup of the book: {{title}}', {
|
||||
title: transfer.bookTitle,
|
||||
}),
|
||||
};
|
||||
const errorMessages = getTransferMessages(transfer, _).failure;
|
||||
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
@@ -478,6 +485,7 @@ class TransferManager {
|
||||
eventDispatcher.dispatch('replica-transfer-complete', {
|
||||
kind,
|
||||
replicaId,
|
||||
reincarnation: transfer.replicaReincarnation,
|
||||
type: 'upload',
|
||||
files,
|
||||
});
|
||||
@@ -485,12 +493,14 @@ class TransferManager {
|
||||
}
|
||||
|
||||
if (transfer.type === 'download') {
|
||||
const base = transfer.replicaBase!;
|
||||
for (const file of files) {
|
||||
await this.appService!.downloadReplicaFile(
|
||||
kind,
|
||||
replicaId,
|
||||
file.logical,
|
||||
file.lfp,
|
||||
base,
|
||||
fileProgressHandler(file.byteSize),
|
||||
);
|
||||
bytesAlreadyDone += file.byteSize;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { TransferItem } from '@/store/transferStore';
|
||||
import type { TranslationFunc } from '@/hooks/useTranslation';
|
||||
|
||||
export interface TransferMessages {
|
||||
success: { upload: string; download: string; delete: string };
|
||||
failure: { upload: string; download: string; delete: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build per-kind toast copy for a TransferItem. Books and replica kinds
|
||||
* (currently just `dictionary`; fonts / textures / OPDS catalogs come
|
||||
* later) get distinct strings so the toast doesn't say "Book uploaded"
|
||||
* when a dictionary just synced.
|
||||
*
|
||||
* Future replica kinds slot into the switch on transfer.replicaKind.
|
||||
*/
|
||||
export const getTransferMessages = (
|
||||
transfer: TransferItem,
|
||||
_: TranslationFunc,
|
||||
): TransferMessages => {
|
||||
const title = transfer.bookTitle;
|
||||
|
||||
if (transfer.kind === 'replica' && transfer.replicaKind === 'dictionary') {
|
||||
return {
|
||||
success: {
|
||||
upload: _('Dictionary uploaded: {{title}}', { title }),
|
||||
download: _('Dictionary downloaded: {{title}}', { title }),
|
||||
delete: _('Deleted cloud copy of the dictionary: {{title}}', { title }),
|
||||
},
|
||||
failure: {
|
||||
upload: _('Failed to upload dictionary: {{title}}', { title }),
|
||||
download: _('Failed to download dictionary: {{title}}', { title }),
|
||||
delete: _('Failed to delete cloud copy of the dictionary: {{title}}', { title }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: {
|
||||
upload: _('Book uploaded: {{title}}', { title }),
|
||||
download: _('Book downloaded: {{title}}', { title }),
|
||||
delete: _('Deleted cloud backup of the book: {{title}}', { title }),
|
||||
},
|
||||
failure: {
|
||||
upload: _('Failed to upload book: {{title}}', { title }),
|
||||
download: _('Failed to download book: {{title}}', { title }),
|
||||
delete: _('Failed to delete cloud backup of the book: {{title}}', { title }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from '@/services/dictionaries/types';
|
||||
import { BUILTIN_PROVIDER_IDS, BUILTIN_WEB_SEARCH_IDS } from '@/services/dictionaries/types';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
import { publishDictionaryDelete, publishDictionaryUpsert } from '@/services/sync/replicaPublish';
|
||||
|
||||
/**
|
||||
* Built-in web-search ids are seeded into `providerOrder` but disabled by
|
||||
@@ -47,6 +48,33 @@ interface DictionaryStoreState {
|
||||
|
||||
/** Add (or revive) an imported dictionary. New entries are appended to providerOrder + enabled. */
|
||||
addDictionary(dict: ImportedDictionary): void;
|
||||
/**
|
||||
* Add a dictionary received via replica sync from another device. Same
|
||||
* effect on local state as addDictionary, but does NOT call
|
||||
* publishDictionaryUpsert — the row already exists on the server (we
|
||||
* just pulled it). Re-publishing would create a tight feedback loop
|
||||
* with stale HLCs. Used exclusively by the pull-side orchestrator.
|
||||
*/
|
||||
applyRemoteDictionary(dict: ImportedDictionary): void;
|
||||
/**
|
||||
* Look up a local imported entry by its cross-device contentId. Used
|
||||
* by the pull-side orchestrator to detect "row from another device"
|
||||
* vs "row originated here" cases.
|
||||
*/
|
||||
findByContentId(contentId: string): ImportedDictionary | undefined;
|
||||
/**
|
||||
* Clears the `unavailable` flag on the dict matching `contentId`. Called
|
||||
* by the replica-transfer-complete listener after a remote-sourced dict
|
||||
* finishes downloading from cloud storage. No-op if the dict isn't found.
|
||||
*/
|
||||
markAvailableByContentId(contentId: string): void;
|
||||
/**
|
||||
* Soft-delete by contentId, skipping the publishDictionaryDelete call
|
||||
* that removeDictionary does. Used by the pull orchestrator when a
|
||||
* server row arrives tombstoned — the row is already deleted on the
|
||||
* server; we just observed it and need to mirror locally.
|
||||
*/
|
||||
softDeleteByContentId(contentId: string): void;
|
||||
/**
|
||||
* Patch an imported dictionary's mutable display fields (currently just
|
||||
* `name`). The on-disk bundle is untouched. No-op if the id is unknown
|
||||
@@ -90,6 +118,35 @@ function toSettingsDict(dict: ImportedDictionary): ImportedDictionary {
|
||||
return rest;
|
||||
}
|
||||
|
||||
// Replica-side mutators (applyRemoteDictionary, softDeleteByContentId,
|
||||
// markAvailableByContentId) fire from boot-time pull / download-complete
|
||||
// handlers, NOT the settings UI. The UI couples its state mutations with
|
||||
// an explicit saveCustomDictionaries(envConfig) call; the replica path
|
||||
// has no such pairing, so without this auto-persist the next
|
||||
// loadCustomDictionaries (run on Annotator / settings panel mount) reads
|
||||
// stale settings.customDictionaries and wipes the in-memory rows.
|
||||
let replicaPersistEnv: EnvConfigType | null = null;
|
||||
export const enableReplicaAutoPersist = (envConfig: EnvConfigType | null): void => {
|
||||
replicaPersistEnv = envConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up a dict by its cross-device contentId, falling back to the
|
||||
* persisted `settings.customDictionaries` when the in-memory store is
|
||||
* empty. The pull-side orchestrator runs at app boot — earlier than
|
||||
* Annotator/CustomDictionaries mount, so loadCustomDictionaries hasn't
|
||||
* hydrated the zustand store yet. Without the fallback every refresh
|
||||
* looks like a brand-new device, mints a fresh bundleDir per row, and
|
||||
* re-downloads all binaries.
|
||||
*/
|
||||
export const findDictionaryByContentId = (contentId: string): ImportedDictionary | undefined => {
|
||||
if (!contentId) return undefined;
|
||||
const inMemory = useCustomDictionaryStore.getState().findByContentId(contentId);
|
||||
if (inMemory) return inMemory;
|
||||
const persisted = useSettingsStore.getState().settings?.customDictionaries ?? [];
|
||||
return persisted.find((d) => d.contentId === contentId && !d.deletedAt);
|
||||
};
|
||||
|
||||
export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get) => ({
|
||||
dictionaries: [],
|
||||
settings: { ...DEFAULT_DICTIONARY_SETTINGS },
|
||||
@@ -120,9 +177,65 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
|
||||
settings: { ...state.settings, providerOrder: order, providerEnabled: enabled },
|
||||
};
|
||||
});
|
||||
void publishDictionaryUpsert(dict);
|
||||
},
|
||||
|
||||
applyRemoteDictionary: (dict) => {
|
||||
// Same local-state mutation as addDictionary, minus the publish call.
|
||||
// The row already exists on the server (we just pulled it).
|
||||
set((state) => {
|
||||
const existingIdx = state.dictionaries.findIndex((d) => d.id === dict.id);
|
||||
const dictionaries =
|
||||
existingIdx >= 0
|
||||
? state.dictionaries.map((d, i) =>
|
||||
i === existingIdx ? { ...dict, deletedAt: undefined } : d,
|
||||
)
|
||||
: [...state.dictionaries, dict];
|
||||
const order = state.settings.providerOrder.includes(dict.id)
|
||||
? state.settings.providerOrder
|
||||
: [...state.settings.providerOrder, dict.id];
|
||||
const enabled = { ...state.settings.providerEnabled };
|
||||
if (!(dict.id in enabled)) enabled[dict.id] = !dict.unsupported;
|
||||
return {
|
||||
dictionaries,
|
||||
settings: { ...state.settings, providerOrder: order, providerEnabled: enabled },
|
||||
};
|
||||
});
|
||||
if (replicaPersistEnv) void get().saveCustomDictionaries(replicaPersistEnv);
|
||||
},
|
||||
|
||||
findByContentId: (contentId) =>
|
||||
contentId ? get().dictionaries.find((d) => d.contentId === contentId) : undefined,
|
||||
|
||||
markAvailableByContentId: (contentId) => {
|
||||
set((state) => ({
|
||||
dictionaries: state.dictionaries.map((d) =>
|
||||
d.contentId === contentId ? { ...d, unavailable: undefined } : d,
|
||||
),
|
||||
}));
|
||||
if (replicaPersistEnv) void get().saveCustomDictionaries(replicaPersistEnv);
|
||||
},
|
||||
|
||||
softDeleteByContentId: (contentId) => {
|
||||
const target = get().dictionaries.find((d) => d.contentId === contentId && !d.deletedAt);
|
||||
if (!target) return;
|
||||
set((state) => ({
|
||||
dictionaries: state.dictionaries.map((d) =>
|
||||
d.id === target.id ? { ...d, deletedAt: Date.now() } : d,
|
||||
),
|
||||
settings: {
|
||||
...state.settings,
|
||||
providerOrder: state.settings.providerOrder.filter((p) => p !== target.id),
|
||||
providerEnabled: Object.fromEntries(
|
||||
Object.entries(state.settings.providerEnabled).filter(([k]) => k !== target.id),
|
||||
),
|
||||
},
|
||||
}));
|
||||
if (replicaPersistEnv) void get().saveCustomDictionaries(replicaPersistEnv);
|
||||
},
|
||||
|
||||
updateDictionary: (id, patch) => {
|
||||
let updated: ImportedDictionary | null = null;
|
||||
set((state) => {
|
||||
const idx = state.dictionaries.findIndex((d) => d.id === id);
|
||||
if (idx < 0) return state;
|
||||
@@ -132,11 +245,11 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
|
||||
// Reject undefined (no patch), empty (would clear the label), and
|
||||
// unchanged (no-op).
|
||||
if (!trimmedName || trimmedName === old.name) return state;
|
||||
const dictionaries = state.dictionaries.map((d, i) =>
|
||||
i === idx ? { ...d, name: trimmedName } : d,
|
||||
);
|
||||
updated = { ...old, name: trimmedName };
|
||||
const dictionaries = state.dictionaries.map((d, i) => (i === idx ? updated! : d));
|
||||
return { dictionaries };
|
||||
});
|
||||
if (updated) void publishDictionaryUpsert(updated);
|
||||
},
|
||||
|
||||
replaceDictionaries: (oldIds, newDict) => {
|
||||
@@ -145,6 +258,13 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
|
||||
return;
|
||||
}
|
||||
const oldIdSet = new Set(oldIds);
|
||||
// Capture contentIds of replaced dicts so we can tombstone them on the
|
||||
// server. Only contentId-bearing entries actually existed cross-device;
|
||||
// legacy bundleDir-only ids never published, so nothing to tombstone.
|
||||
const oldContentIds = get()
|
||||
.dictionaries.filter((d) => oldIdSet.has(d.id))
|
||||
.map((d) => d.contentId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
set((state) => {
|
||||
// Drop all old entries (hard-remove since the disk bundles are gone)
|
||||
// and append the new one. Soft-delete isn't needed: the previously
|
||||
@@ -185,6 +305,19 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
|
||||
settings: { ...state.settings, providerOrder, providerEnabled },
|
||||
};
|
||||
});
|
||||
// When reincarnating (re-import after delete), the server-side row is
|
||||
// already tombstoned — re-publishing the tombstone is redundant. The
|
||||
// upsert below carries a reincarnation token so clients see the row
|
||||
// as alive again. For non-reincarnation replacements (re-import of a
|
||||
// still-live entry, importer collapsing duplicate names), we skip
|
||||
// tombstoning if the contentId is preserved across the swap (same
|
||||
// content → same row → no need to delete then immediately recreate).
|
||||
const isContentSurvivingSwap =
|
||||
Boolean(newDict.contentId) && oldContentIds.includes(newDict.contentId!);
|
||||
if (!isContentSurvivingSwap) {
|
||||
for (const contentId of oldContentIds) void publishDictionaryDelete(contentId);
|
||||
}
|
||||
void publishDictionaryUpsert(newDict);
|
||||
},
|
||||
|
||||
removeDictionary: (id) => {
|
||||
@@ -202,6 +335,7 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
|
||||
),
|
||||
},
|
||||
}));
|
||||
if (dict.contentId) void publishDictionaryDelete(dict.contentId);
|
||||
return true;
|
||||
},
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface TransferItem {
|
||||
bookTitle: string;
|
||||
replicaKind?: string;
|
||||
replicaId?: string;
|
||||
replicaReincarnation?: string;
|
||||
replicaFiles?: ReplicaTransferFile[];
|
||||
replicaBase?: BaseDir;
|
||||
type: TransferType;
|
||||
@@ -64,6 +65,7 @@ interface TransferState {
|
||||
isBackground?: boolean;
|
||||
files?: ReplicaTransferFile[];
|
||||
base?: BaseDir;
|
||||
reincarnation?: string;
|
||||
},
|
||||
) => string;
|
||||
removeTransfer: (transferId: string) => void;
|
||||
@@ -160,6 +162,7 @@ export const useTransferStore = create<TransferState>((set, get) => ({
|
||||
bookTitle: displayTitle,
|
||||
replicaKind,
|
||||
replicaId,
|
||||
replicaReincarnation: opts.reincarnation,
|
||||
replicaFiles: opts.files,
|
||||
replicaBase: opts.base,
|
||||
type,
|
||||
|
||||
@@ -139,6 +139,13 @@ export interface SystemSettings {
|
||||
lastSyncedAtBooks: number;
|
||||
lastSyncedAtConfigs: number;
|
||||
lastSyncedAtNotes: number;
|
||||
/**
|
||||
* Per-device id used as the deviceId portion of every HLC this device
|
||||
* mints. Lazy-generated on first sync init via uuidv4 (mirrors
|
||||
* kosync.deviceId). Independent from kosync — the two services have
|
||||
* distinct identifier semantics and rotation policies.
|
||||
*/
|
||||
replicaDeviceId?: string;
|
||||
/**
|
||||
* Per-kind cursor for replica sync. Stores the HLC string of the last
|
||||
* pulled row per kind. Absent kinds pull from the beginning.
|
||||
|
||||
@@ -166,7 +166,8 @@ export interface AppService {
|
||||
kind: string,
|
||||
replicaId: string,
|
||||
filename: string,
|
||||
dst: string,
|
||||
lfp: string,
|
||||
base: BaseDir,
|
||||
onProgress?: ProgressHandler,
|
||||
): Promise<void>;
|
||||
deleteReplicaBundle(kind: string, replicaId: string, filenames: string[]): Promise<void>;
|
||||
|
||||
@@ -41,8 +41,8 @@ CREATE POLICY replica_keys_delete ON public.replica_keys
|
||||
-- revive a tombstoned row.
|
||||
-- reincarnation — explicit re-import token; swaps row to alive under a
|
||||
-- new logical identity.
|
||||
-- updated_at_ts — max(field HLCs, deleted_at_ts). Used as the pull
|
||||
-- cursor.
|
||||
-- updated_at_ts — max(field HLCs, deleted_at_ts, row-level operation
|
||||
-- HLCs such as manifest commits). Used as the pull cursor.
|
||||
-- schema_version — per-kind schema bump; server enforces bounds.
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.replicas (
|
||||
|
||||
@@ -79,7 +79,7 @@ END;
|
||||
$$;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- updated_at_ts = max over field HLCs and tombstone HLC.
|
||||
-- Content updated_at_ts = max over field HLCs and tombstone HLC.
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.crdt_compute_updated_at(fields jsonb, deleted_at text)
|
||||
RETURNS text
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
-- Migration 005: keep replica updated_at_ts as the max row-operation HLC.
|
||||
--
|
||||
-- Migration 004 recomputed updated_at_ts from fields_jsonb + deleted_at_ts
|
||||
-- after every conflict. That loses manifest-only operation timestamps:
|
||||
-- manifest_jsonb can change, but pull cursors do not advance, so a device
|
||||
-- that already pulled the metadata-only row can miss the downloadable
|
||||
-- transition. Preserve the max of existing row timestamp, incoming row
|
||||
-- timestamp, and content/tombstone timestamp.
|
||||
--
|
||||
-- Also treat incoming manifest_jsonb = null as "no manifest update" on
|
||||
-- conflict. Metadata-only rows use null, and must not clear an existing
|
||||
-- committed manifest.
|
||||
--
|
||||
-- Reincarnation is derived from the newest non-null token candidate that
|
||||
-- is newer than the merged tombstone. Null metadata/manifest rows do not
|
||||
-- clear a token; a newer tombstone clears it because no token candidate is
|
||||
-- newer than that tombstone. Without this, editing a reincarnated
|
||||
-- dictionary title publishes p_reincarnation = null and erases the revival
|
||||
-- token.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.crdt_merge_replica(
|
||||
p_user_id uuid,
|
||||
p_kind text,
|
||||
p_replica_id text,
|
||||
p_fields_jsonb jsonb,
|
||||
p_manifest_jsonb jsonb,
|
||||
p_deleted_at_ts text,
|
||||
p_reincarnation text,
|
||||
p_updated_at_ts text,
|
||||
p_schema_version integer
|
||||
) RETURNS public.replicas
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
result public.replicas;
|
||||
BEGIN
|
||||
INSERT INTO public.replicas AS r (
|
||||
user_id, kind, replica_id,
|
||||
fields_jsonb, manifest_jsonb, deleted_at_ts,
|
||||
reincarnation, updated_at_ts, schema_version
|
||||
) VALUES (
|
||||
p_user_id, p_kind, p_replica_id,
|
||||
COALESCE(p_fields_jsonb, '{}'::jsonb),
|
||||
p_manifest_jsonb, p_deleted_at_ts,
|
||||
p_reincarnation, p_updated_at_ts, p_schema_version
|
||||
)
|
||||
ON CONFLICT (user_id, kind, replica_id) DO UPDATE SET
|
||||
fields_jsonb = public.crdt_merge_fields(r.fields_jsonb, EXCLUDED.fields_jsonb),
|
||||
deleted_at_ts = public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts),
|
||||
reincarnation = CASE
|
||||
WHEN r.reincarnation IS NULL AND EXCLUDED.reincarnation IS NULL
|
||||
THEN NULL
|
||||
WHEN r.reincarnation IS NOT NULL AND EXCLUDED.reincarnation IS NULL
|
||||
THEN CASE
|
||||
WHEN public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) IS NULL
|
||||
OR r.updated_at_ts > public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts)
|
||||
THEN r.reincarnation
|
||||
ELSE NULL
|
||||
END
|
||||
WHEN r.reincarnation IS NULL AND EXCLUDED.reincarnation IS NOT NULL
|
||||
THEN CASE
|
||||
WHEN public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) IS NULL
|
||||
OR EXCLUDED.updated_at_ts > public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts)
|
||||
THEN EXCLUDED.reincarnation
|
||||
ELSE NULL
|
||||
END
|
||||
WHEN EXCLUDED.updated_at_ts > r.updated_at_ts
|
||||
THEN CASE
|
||||
WHEN public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) IS NULL
|
||||
OR EXCLUDED.updated_at_ts > public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts)
|
||||
THEN EXCLUDED.reincarnation
|
||||
ELSE NULL
|
||||
END
|
||||
ELSE CASE
|
||||
WHEN public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) IS NULL
|
||||
OR r.updated_at_ts > public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts)
|
||||
THEN r.reincarnation
|
||||
ELSE NULL
|
||||
END
|
||||
END,
|
||||
manifest_jsonb = CASE
|
||||
WHEN EXCLUDED.manifest_jsonb IS NULL
|
||||
THEN r.manifest_jsonb
|
||||
WHEN r.manifest_jsonb IS NULL
|
||||
THEN EXCLUDED.manifest_jsonb
|
||||
WHEN EXCLUDED.updated_at_ts > r.updated_at_ts
|
||||
THEN EXCLUDED.manifest_jsonb
|
||||
ELSE r.manifest_jsonb
|
||||
END,
|
||||
schema_version = GREATEST(r.schema_version, EXCLUDED.schema_version),
|
||||
updated_at_ts = public.hlc_max(
|
||||
public.hlc_max(r.updated_at_ts, EXCLUDED.updated_at_ts),
|
||||
public.crdt_compute_updated_at(
|
||||
public.crdt_merge_fields(r.fields_jsonb, EXCLUDED.fields_jsonb),
|
||||
public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts)
|
||||
)
|
||||
),
|
||||
modified_at = now()
|
||||
RETURNING * INTO result;
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.crdt_merge_replica(uuid, text, text, jsonb, jsonb, text, text, text, integer) TO authenticated;
|
||||
Reference in New Issue
Block a user