mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -4,6 +4,7 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { ConnectionMonitor } from "./components/common/connection-monitor";
|
||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||
import { MigrationBanner } from "./components/common/migration-banner";
|
||||
import { RouteAnnouncer } from "./components/common/route-announcer";
|
||||
import { UsageSurveyOverlay } from "./components/onboarding/usage-survey-overlay";
|
||||
import { I18nProvider } from "./contexts/i18n-context";
|
||||
@@ -193,6 +194,7 @@ export function App() {
|
||||
<RouteAnnouncer />
|
||||
<KeyboardShortcutProvider>
|
||||
<AuthGuard>
|
||||
<MigrationBanner />
|
||||
<UsageSurveyOverlay />
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { CheckCircle2, TriangleAlert, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet, apiPut } from "@/lib/api";
|
||||
import { parseMigrationMarker, shouldShowMigrationBanner } from "@/lib/feedback";
|
||||
import { format } from "@/lib/format";
|
||||
import { withTimeout } from "@/lib/with-timeout";
|
||||
|
||||
// A hung write must not leave the dismiss button stuck; time it out so the admin
|
||||
// can retry (worst case the banner reappears next load, which is harmless).
|
||||
const WRITE_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* One-time admin banner announcing the result of a 1.x SQLite import (or warning
|
||||
* that a 1.x database was found but not imported). Reads the `sqlite_import`
|
||||
* marker from the settings payload and persists dismissal to a settings key,
|
||||
* mirroring the usage-survey overlay pattern.
|
||||
*/
|
||||
export function MigrationBanner() {
|
||||
const { t } = useTranslation();
|
||||
const { role } = useAuth();
|
||||
const [settings, setSettings] = useState<Record<string, string> | null>(null);
|
||||
const [dismissing, setDismissing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (role !== "admin") return;
|
||||
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
||||
.then((data) => setSettings(data.settings))
|
||||
.catch(() => {
|
||||
// Fail closed: no settings means we cannot know the import state, so we
|
||||
// simply do not show the banner for this load.
|
||||
});
|
||||
}, [role]);
|
||||
|
||||
if (settings === null || !shouldShowMigrationBanner({ settings, role })) return null;
|
||||
const marker = parseMigrationMarker(settings.sqlite_import);
|
||||
if (!marker) return null;
|
||||
|
||||
const locked = marker.status === "detected_locked";
|
||||
const users = marker.tables?.users ?? 0;
|
||||
const files = marker.tables?.user_files ?? 0;
|
||||
|
||||
async function dismiss() {
|
||||
if (dismissing) return;
|
||||
setDismissing(true);
|
||||
const value = new Date().toISOString();
|
||||
try {
|
||||
await withTimeout(
|
||||
apiPut("/v1/settings", { "sqlite_import.dismissedAt": value }),
|
||||
WRITE_TIMEOUT_MS,
|
||||
);
|
||||
setSettings((current) => ({ ...(current ?? {}), "sqlite_import.dismissedAt": value }));
|
||||
} catch {
|
||||
// Leave it visible next load; a failed dismiss is low-stakes.
|
||||
} finally {
|
||||
setDismissing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`fixed top-0 left-0 right-0 z-[55] flex items-center justify-center gap-3 px-4 py-2 text-sm font-medium ${
|
||||
locked
|
||||
? "bg-amber-500 text-amber-950 dark:bg-amber-600 dark:text-amber-50"
|
||||
: "bg-emerald-500 text-emerald-950 dark:bg-emerald-600 dark:text-emerald-50"
|
||||
}`}
|
||||
>
|
||||
{locked ? (
|
||||
<TriangleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
<span className="text-center">
|
||||
<span className="font-semibold">
|
||||
{locked ? t.migrationBanner.warningTitle : t.migrationBanner.successTitle}
|
||||
</span>{" "}
|
||||
{locked
|
||||
? t.migrationBanner.warningBody
|
||||
: format(t.migrationBanner.successBody, { users, files })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
disabled={dismissing}
|
||||
aria-label={t.migrationBanner.dismiss}
|
||||
className="shrink-0 rounded p-0.5 hover:bg-black/10 disabled:opacity-50"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -152,6 +152,40 @@ export function shouldShowUsageSurvey({
|
||||
);
|
||||
}
|
||||
|
||||
export interface MigrationMarker {
|
||||
status: "completed" | "detected_locked";
|
||||
tables?: Record<string, number>;
|
||||
blobs?: { present: number; missing: number };
|
||||
}
|
||||
|
||||
/** Parse the `sqlite_import` settings marker written by the 1.x import on boot. */
|
||||
export function parseMigrationMarker(raw: string | undefined): MigrationMarker | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const marker = JSON.parse(raw) as MigrationMarker;
|
||||
return marker.status === "completed" || marker.status === "detected_locked" ? marker : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the 1.x migration banner to admins when an import marker exists and has
|
||||
* not been dismissed. The marker key is admin-only (SENSITIVE_KEYS on the API),
|
||||
* so non-admins never receive it, and we gate on role here as well.
|
||||
*/
|
||||
export function shouldShowMigrationBanner({
|
||||
settings,
|
||||
role,
|
||||
}: {
|
||||
settings: Record<string, string>;
|
||||
role: string | null;
|
||||
}): boolean {
|
||||
if (role !== "admin") return false;
|
||||
if (settings["sqlite_import.dismissedAt"]) return false;
|
||||
return parseMigrationMarker(settings.sqlite_import) !== null;
|
||||
}
|
||||
|
||||
export async function submitFeedback(payload: FeedbackPayload): Promise<FeedbackResponse> {
|
||||
return apiPost<FeedbackResponse>("/v1/feedback", payload);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user