fix(migrator): correct and harden the 1.x to 2.0 SQLite import (#434)

* feat(api): parse DATA_DIR from env for 1.x import auto-detection

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* test(migrator): build 1.17.2 fixtures by replaying legacy migrations

Discovered the legacy migrations seed a Default team (0005) and builtin roles
(0007), so the replayed fixture carries them. Seed uses a distinct custom team.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* fix(migrator): self-adjusting column copy, jobs.status map, drop sessions, advisory lock

The importer now inserts only the intersection of source and live target columns,
so the three analytics_* columns 2.x dropped no longer break the first users INSERT
(and future dropped columns are handled generically). jobs.status is mapped onto the
2.x enum (error->failed). Sessions are no longer migrated. A pg_advisory_xact_lock
serializes concurrent replicas. Includes login-after-migrate and library assertions.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* test(migrator): CI drift guard fails when a required column is unfillable from 1.17.2

Introspects every NOT-NULL-no-default column of each migrated table in the current
schema and asserts the engine can fill it from a real 1.17.2 source. Turns a future
breaking schema change into a PR-time failure instead of a production import break.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* feat(migrator): orchestrator with detection, boot states, marker, blob count

sqlite-import.ts owns source resolution (explicit path, 'off' sentinel, DATA_DIR
probe), the four boot states (import/leftover/locked/none), the persisted
sqlite_import marker, and a read-only library-blob count. runBootImport wires them
together and catches TargetNonEmptyError as a benign multi-replica skip.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* feat(api): route boot through the 1.x import orchestrator; hide marker from non-admins

index.ts now calls runBootImport (which owns detection + the four boot states)
instead of the inline SQLITE_MIGRATE_PATH block. The sqlite_import marker is added
to SENSITIVE_KEYS (but not REDACTED_KEYS) so admins see the counts for the banner
while non-admins don't see the key at all.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* feat(migrator): add analyzeSqlite + dry-run/verify CLI

analyzeSqlite is a read-only pre-flight (no live Postgres): per-table row counts,
library-blob presence, and out-of-enum job statuses. The migrate:sqlite CLI now
lives in the orchestrator and supports --dry-run/--verify (prints the analysis and
exits without writing) alongside the existing import and --force.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* docs: add 1.x to 2.0 upgrade guide; fix volume-name casing

New apps/docs upgrade guide covering auto-detect, the SQLITE_MIGRATE_PATH override +
off opt-out, the dry-run, what carries over, locked-state recovery, and non-destructive
rollback. Leads with 'back up the WHOLE /data volume, not just snapotter.db' because
1.x WAL mode leaves data in snapotter.db-wal (surfaced by the real-image upgrade test).
Standardizes README/DOCKERHUB compose volume names on the canonical SnapOtter-data
casing so they match the repo compose and don't orphan an upgrader's volume.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* feat(web): admin 1.x migration banner + 21-locale strings

A one-time admin banner reads the sqlite_import marker from /v1/settings and shows
the import result (user + saved-file counts) on success, or a warning when a 1.x
database was found but not imported. Dismissal persists to a sqlite_import.dismissedAt
settings key. shouldShowMigrationBanner/parseMigrationMarker sit in feedback.ts with
the other shouldShow helpers; strings added to en.ts and all 20 other locales.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* style(landing): biome-format Hero.astro trustBadges array

Pre-existing formatting drift on main (its Lint check was skipped on the merge that
introduced it); this PR's full Lint run surfaced it. Formatting-only, applied via
the repo's own biome formatter to unblock the required Lint check.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w
This commit is contained in:
SnapOtter
2026-07-04 15:15:39 +00:00
committed by GitHub
parent dc589fd0a1
commit dadf766899
43 changed files with 1206 additions and 87 deletions
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationKeys } from "./en.js";
export const ar: TranslationKeys = {
migrationBanner: {
successTitle: "تم استيراد بيانات SnapOtter 1.x.",
successBody: "تم نقل {users} مستخدمين و {files} ملفات محفوظة من إصدارك السابق.",
warningTitle: "تم العثور على قاعدة بيانات 1.x ولكن لم يتم استيرادها.",
warningBody:
"يحتوي هذا المثيل على بيانات بالفعل، لذلك لم يتم استيراد قاعدة بيانات 1.x. يتطلب الاستيراد مثيلاً فارغاً. راجع دليل الترقية.",
dismiss: "إغلاق",
},
common: {
upload: "رفع من الجهاز",
process: "معالجة",
+9
View File
@@ -1,6 +1,15 @@
import type { TranslationKeys } from "./en.js";
export const de: TranslationKeys = {
migrationBanner: {
successTitle: "SnapOtter 1.x-Daten importiert.",
successBody:
"{users} Benutzer und {files} gespeicherte Dateien aus deiner vorherigen Version übernommen.",
warningTitle: "1.x-Datenbank gefunden, aber nicht importiert.",
warningBody:
"Diese Instanz enthält bereits Daten, daher wurde deine 1.x-Datenbank nicht importiert. Der Import erfordert eine leere Instanz. Siehe die Upgrade-Anleitung.",
dismiss: "Schließen",
},
common: {
upload: "Vom Computer hochladen",
process: "Verarbeiten",
+8
View File
@@ -1,4 +1,12 @@
export const en = {
migrationBanner: {
successTitle: "SnapOtter 1.x data imported.",
successBody: "Brought over {users} users and {files} saved files from your previous version.",
warningTitle: "1.x database found but not imported.",
warningBody:
"This instance already has data, so your 1.x database was not imported. Importing requires an empty instance. See the upgrade guide.",
dismiss: "Dismiss",
},
common: {
upload: "Upload from computer",
process: "Process",
+9
View File
@@ -1,6 +1,15 @@
import type { TranslationKeys } from "./en.js";
export const es: TranslationKeys = {
migrationBanner: {
successTitle: "Datos de SnapOtter 1.x importados.",
successBody:
"Se migraron {users} usuarios y {files} archivos guardados de tu versión anterior.",
warningTitle: "Se encontró una base de datos 1.x, pero no se importó.",
warningBody:
"Esta instancia ya tiene datos, por lo que tu base de datos 1.x no se importó. La importación requiere una instancia vacía. Consulta la guía de actualización.",
dismiss: "Descartar",
},
common: {
upload: "Subir desde la computadora",
process: "Procesar",
+9
View File
@@ -1,6 +1,15 @@
import type { TranslationKeys } from "./en.js";
export const fr: TranslationKeys = {
migrationBanner: {
successTitle: "Données SnapOtter 1.x importées.",
successBody:
"{users} utilisateurs et {files} fichiers enregistrés ont été migrés depuis votre version précédente.",
warningTitle: "Base de données 1.x détectée, mais non importée.",
warningBody:
"Cette instance contient déjà des données, votre base de données 1.x n'a donc pas été importée. L'importation nécessite une instance vide. Consultez le guide de mise à niveau.",
dismiss: "Ignorer",
},
common: {
upload: "Importer depuis l'ordinateur",
process: "Traiter",
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationKeys } from "./en.js";
export const hi: TranslationKeys = {
migrationBanner: {
successTitle: "SnapOtter 1.x डेटा आयात किया गया।",
successBody: "आपके पिछले संस्करण से {users} उपयोगकर्ता और {files} सहेजी गई फ़ाइलें स्थानांतरित की गईं।",
warningTitle: "1.x डेटाबेस मिला, लेकिन आयात नहीं किया गया।",
warningBody:
"इस इंस्टेंस में पहले से डेटा है, इसलिए आपका 1.x डेटाबेस आयात नहीं किया गया। आयात के लिए खाली इंस्टेंस आवश्यक है। अपग्रेड गाइड देखें।",
dismiss: "बंद करें",
},
common: {
upload: "कंप्यूटर से अपलोड करें",
process: "प्रोसेस करें",
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationKeys } from "./en.js";
export const id: TranslationKeys = {
migrationBanner: {
successTitle: "Data SnapOtter 1.x diimpor.",
successBody: "Memindahkan {users} pengguna dan {files} file tersimpan dari versi sebelumnya.",
warningTitle: "Database 1.x ditemukan, tetapi tidak diimpor.",
warningBody:
"Instans ini sudah memiliki data, jadi database 1.x Anda tidak diimpor. Impor memerlukan instans kosong. Lihat panduan peningkatan.",
dismiss: "Tutup",
},
common: {
upload: "Unggah dari komputer",
process: "Proses",
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationKeys } from "./en.js";
export const it: TranslationKeys = {
migrationBanner: {
successTitle: "Dati di SnapOtter 1.x importati.",
successBody: "Migrati {users} utenti e {files} file salvati dalla versione precedente.",
warningTitle: "Database 1.x rilevato, ma non importato.",
warningBody:
"Questa istanza contiene già dati, quindi il database 1.x non è stato importato. L'importazione richiede un'istanza vuota. Consulta la guida all'aggiornamento.",
dismiss: "Ignora",
},
common: {
upload: "Carica dal computer",
process: "Elabora",
+9
View File
@@ -1,6 +1,15 @@
import type { TranslationKeys } from "./en.js";
export const ja: TranslationKeys = {
migrationBanner: {
successTitle: "SnapOtter 1.x のデータをインポートしました。",
successBody:
"以前のバージョンから {users} 人のユーザーと {files} 件の保存済みファイルを移行しました。",
warningTitle: "1.x データベースが見つかりましたが、インポートされていません。",
warningBody:
"このインスタンスには既にデータがあるため、1.x データベースはインポートされませんでした。インポートには空のインスタンスが必要です。アップグレードガイドをご覧ください。",
dismiss: "閉じる",
},
common: {
upload: "パソコンからアップロード",
process: "処理",
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationKeys } from "./en.js";
export const ko: TranslationKeys = {
migrationBanner: {
successTitle: "SnapOtter 1.x 데이터를 가져왔습니다.",
successBody: "이전 버전에서 사용자 {users}명과 저장된 파일 {files}개를 이전했습니다.",
warningTitle: "1.x 데이터베이스를 찾았지만 가져오지 않았습니다.",
warningBody:
"이 인스턴스에 이미 데이터가 있어 1.x 데이터베이스를 가져오지 않았습니다. 가져오려면 빈 인스턴스가 필요합니다. 업그레이드 가이드를 참조하세요.",
dismiss: "닫기",
},
common: {
upload: "컴퓨터에서 업로드",
process: "처리",
+9
View File
@@ -1,6 +1,15 @@
import type { TranslationKeys } from "./en.js";
export const nl: TranslationKeys = {
migrationBanner: {
successTitle: "SnapOtter 1.x-gegevens geïmporteerd.",
successBody:
"{users} gebruikers en {files} opgeslagen bestanden overgezet vanuit je vorige versie.",
warningTitle: "1.x-database gevonden, maar niet geïmporteerd.",
warningBody:
"Deze instantie bevat al gegevens, dus je 1.x-database is niet geïmporteerd. Voor importeren is een lege instantie nodig. Zie de upgradehandleiding.",
dismiss: "Sluiten",
},
common: {
upload: "Upload van computer",
process: "Verwerken",
+9
View File
@@ -1,6 +1,15 @@
import type { TranslationKeys } from "./en.js";
export const pl: TranslationKeys = {
migrationBanner: {
successTitle: "Zaimportowano dane SnapOtter 1.x.",
successBody:
"Przeniesiono {users} użytkowników i {files} zapisanych plików z poprzedniej wersji.",
warningTitle: "Znaleziono bazę danych 1.x, ale jej nie zaimportowano.",
warningBody:
"Ta instancja zawiera już dane, więc baza danych 1.x nie została zaimportowana. Import wymaga pustej instancji. Zobacz przewodnik aktualizacji.",
dismiss: "Zamknij",
},
common: {
upload: "Prześlij z komputera",
process: "Przetwórz",
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationKeys } from "./en.js";
export const ptBR: TranslationKeys = {
migrationBanner: {
successTitle: "Dados do SnapOtter 1.x importados.",
successBody: "Migramos {users} usuários e {files} arquivos salvos da sua versão anterior.",
warningTitle: "Banco de dados 1.x encontrado, mas não importado.",
warningBody:
"Esta instância já tem dados, então seu banco de dados 1.x não foi importado. A importação exige uma instância vazia. Consulte o guia de atualização.",
dismiss: "Dispensar",
},
common: {
upload: "Enviar do computador",
process: "Processar",
+9
View File
@@ -1,6 +1,15 @@
import type { TranslationKeys } from "./en.js";
export const ru: TranslationKeys = {
migrationBanner: {
successTitle: "Данные SnapOtter 1.x импортированы.",
successBody:
"Перенесено пользователей: {users}, сохранённых файлов: {files} из предыдущей версии.",
warningTitle: "База данных 1.x найдена, но не импортирована.",
warningBody:
"В этом экземпляре уже есть данные, поэтому база данных 1.x не была импортирована. Для импорта нужен пустой экземпляр. См. руководство по обновлению.",
dismiss: "Закрыть",
},
common: {
upload: "Загрузить с компьютера",
process: "Обработать",
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationKeys } from "./en.js";
export const sv: TranslationKeys = {
migrationBanner: {
successTitle: "SnapOtter 1.x-data importerad.",
successBody: "Överförde {users} användare och {files} sparade filer från din tidigare version.",
warningTitle: "1.x-databas hittades men importerades inte.",
warningBody:
"Den här instansen har redan data, så din 1.x-databas importerades inte. Import kräver en tom instans. Se uppgraderingsguiden.",
dismiss: "Stäng",
},
common: {
upload: "Ladda upp från dator",
process: "Bearbeta",
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationKeys } from "./en.js";
export const th: TranslationKeys = {
migrationBanner: {
successTitle: "นำเข้าข้อมูล SnapOtter 1.x แล้ว",
successBody: "ย้าย {users} ผู้ใช้และ {files} ไฟล์ที่บันทึกไว้จากเวอร์ชันก่อนหน้าของคุณ",
warningTitle: "พบฐานข้อมูล 1.x แต่ยังไม่ได้นำเข้า",
warningBody:
"อินสแตนซ์นี้มีข้อมูลอยู่แล้ว จึงไม่ได้นำเข้าฐานข้อมูล 1.x ของคุณ การนำเข้าต้องใช้อินสแตนซ์ที่ว่างเปล่า ดูคู่มือการอัปเกรด",
dismiss: "ปิด",
},
common: {
upload: "อัปโหลดจากคอมพิวเตอร์",
process: "ประมวลผล",
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationKeys } from "./en.js";
export const tr: TranslationKeys = {
migrationBanner: {
successTitle: "SnapOtter 1.x verileri içe aktarıldı.",
successBody: "Önceki sürümünüzden {users} kullanıcı ve {files} kayıtlı dosya taşındı.",
warningTitle: "1.x veritabanı bulundu ancak içe aktarılmadı.",
warningBody:
"Bu örnekte zaten veri var, bu yüzden 1.x veritabanınız içe aktarılmadı. İçe aktarma boş bir örnek gerektirir. Yükseltme kılavuzuna bakın.",
dismiss: "Kapat",
},
common: {
upload: "Bilgisayardan yükle",
process: "İşle",
+9
View File
@@ -1,6 +1,15 @@
import type { TranslationKeys } from "./en.js";
export const uk: TranslationKeys = {
migrationBanner: {
successTitle: "Дані SnapOtter 1.x імпортовано.",
successBody:
"Перенесено {users} користувачів і {files} збережених файлів із попередньої версії.",
warningTitle: "Базу даних 1.x знайдено, але не імпортовано.",
warningBody:
"Цей екземпляр уже містить дані, тому базу даних 1.x не було імпортовано. Для імпорту потрібен порожній екземпляр. Див. посібник з оновлення.",
dismiss: "Закрити",
},
common: {
upload: "Завантажити з комп'ютера",
process: "Обробити",
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationKeys } from "./en.js";
export const vi: TranslationKeys = {
migrationBanner: {
successTitle: "Đã nhập dữ liệu SnapOtter 1.x.",
successBody: "Đã chuyển {users} người dùng và {files} tệp đã lưu từ phiên bản trước của bạn.",
warningTitle: "Đã tìm thấy cơ sở dữ liệu 1.x nhưng chưa nhập.",
warningBody:
"Phiên bản này đã có dữ liệu nên cơ sở dữ liệu 1.x của bạn chưa được nhập. Việc nhập yêu cầu một phiên bản trống. Xem hướng dẫn nâng cấp.",
dismiss: "Đóng",
},
common: {
upload: "Tải lên từ máy tính",
process: "Xử lý",
+7
View File
@@ -1,6 +1,13 @@
import type { TranslationKeys } from "./en.js";
export const zhCN: TranslationKeys = {
migrationBanner: {
successTitle: "已导入 SnapOtter 1.x 数据。",
successBody: "已从旧版本迁移 {users} 个用户和 {files} 个已保存文件。",
warningTitle: "发现 1.x 数据库,但未导入。",
warningBody: "此实例已有数据,因此未导入你的 1.x 数据库。导入需要一个空实例。请参阅升级指南。",
dismiss: "关闭",
},
common: {
upload: "从电脑上传",
process: "处理",
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationKeys } from "./en.js";
export const zhTW: TranslationKeys = {
migrationBanner: {
successTitle: "已匯入 SnapOtter 1.x 資料。",
successBody: "已從舊版本移轉 {users} 位使用者與 {files} 個已儲存檔案。",
warningTitle: "偵測到 1.x 資料庫,但未匯入。",
warningBody:
"此執行個體已有資料,因此未匯入您的 1.x 資料庫。匯入需要空白的執行個體。請參閱升級指南。",
dismiss: "關閉",
},
common: {
upload: "從電腦上傳",
process: "處理",