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
+2
View File
@@ -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>
);
}
+34
View File
@@ -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);
}