Files
dbx/apps/desktop/src/stores/migrationStore.ts
T

119 lines
4.9 KiB
TypeScript

import { computed, reactive, ref } from "vue";
import * as api from "@/lib/backend/api";
import type { MigrationPreflight, MigrationReport } from "@/lib/backend/migration";
type MigrationApi = Pick<typeof api, "migrationStatus" | "migrationStart" | "migrationRetry" | "migrationCleanupBackups">;
// Each startup boundary owns its state; an action failure never erases its report.
export function useMigrationStore(backend: MigrationApi = api) {
const state = reactive<{
status: MigrationPreflight | null;
report: MigrationReport | null;
loading: boolean;
busy: boolean;
error: "statusFailed" | "migrationFailed" | "cleanupFailed" | null;
errorCode: string | null;
errorMessage: string | null;
entered: boolean;
step: 1 | 2 | 3;
}>({ status: null, report: null, loading: true, busy: false, error: null, errorCode: null, errorMessage: null, entered: false, step: 1 });
const migrationAttempted = ref(false);
const completed = computed(() => state.status !== null && !state.status.needsMigration && ["succeeded", "not_required"].includes(state.status.state));
// A managed data-directory key is intentionally created when migration
// starts. Its absence during the read-only preflight is actionable, not a
// migration failure, as long as the backend explicitly allows creation.
const hasBlockingError = () => Boolean(state.errorCode && state.status?.keyStatus !== "will_create" && !(state.errorCode === "MISSING_MANAGED_KEY" && state.status?.keyCreationAllowed === true));
// A retained backup is a recovery asset and must not make the success page
// reappear on later launches. An attempt in this session keeps the wizard
// visible even when its response is lost, until the user enters the app.
const blocking = computed(() => state.loading || state.busy || state.error === "statusFailed" || !completed.value || (!state.entered && (migrationAttempted.value || Boolean(state.report))));
function clearError() {
state.error = null;
state.errorCode = null;
state.errorMessage = null;
}
async function refreshStatus() {
// Only this endpoint returns classified, redacted migration errors. Transport
// exceptions may contain configuration values and must never enter UI state.
state.status = await backend.migrationStatus();
state.errorCode = state.status.errorCode ?? null;
state.errorMessage = state.status.errorMessage ?? null;
}
async function initialize() {
state.loading = true;
clearError();
try {
await refreshStatus();
state.step = completed.value ? 3 : 1;
// A completed migration only needs the success page in the session that
// performed it. On later launches, retained backups are recovery assets,
// not a reason to block the normal application startup. `entered` is
// therefore only changed by the explicit success-page action below.
state.entered = false;
if (state.status?.state === "failed" || hasBlockingError()) state.error = "migrationFailed";
} catch {
state.error = "statusFailed";
} finally {
state.loading = false;
}
}
async function run(retry: boolean) {
if (state.busy) return;
state.busy = true;
migrationAttempted.value = true;
state.step = 2;
clearError();
try {
state.report = await (retry ? backend.migrationRetry() : backend.migrationStart());
} catch {
state.error = "migrationFailed";
}
try {
await refreshStatus();
if (completed.value) {
state.step = 3;
state.error = null;
}
if (!completed.value || hasBlockingError()) state.error = "migrationFailed";
} catch {
state.error = "statusFailed";
} finally {
state.busy = false;
}
}
async function cleanup() {
if (state.busy) return;
state.busy = true;
clearError();
try {
await backend.migrationCleanupBackups();
if (state.status) state.status.backupPath = null;
if (state.report) state.report.backupPath = null;
} catch {
state.error = "cleanupFailed";
} finally {
state.busy = false;
}
}
function diagnostic() {
const s = state.status;
return {
migrationId: s?.migrationId,
actionError: state.error,
step: state.step,
needsMigration: s?.needsMigration,
legacyJsonFiles: s?.legacyJsonFiles,
state: s?.state,
counts: s ? { connections: s.connectionCount, databasePlaintext: s.databasePlaintextCount, plugin: s.pluginSecretCount, ai: s.aiSecretCount, tunnel: s.tunnelSecretCount, sync: s.syncCredentialCount } : {},
keyProviderAvailable: s?.keyProviderAvailable,
keyStatus: s?.keyStatus,
backupPath: s?.backupPath,
errorCode: state.errorCode,
};
}
function enter() {
if (completed.value && !state.busy) state.entered = true;
}
return { state, blocking, completed, initialize, start: () => run(false), retry: () => run(true), cleanup, diagnostic, enter };
}