Ctrl K
ComunidadGuías…

scripts/restore-lib.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { decodeKeyBytes } from "#crypto/encryption.ts";
import {
  type BackupManifest,
  PostResetError,
  type RestoreProgress,
  type RestoreProgressHandler,
  type RestoreStage,
} from "#db/backup.ts";
import { SCHEMA_HASH } from "#db/migrations.ts";
import { sum } from "#fp";
import type { ScriptIo } from "#scripts/script-runner.ts";
import { errorMessage } from "#shared/error-message.ts";
import { countLabel, formatBytes } from "#shared/format-units.ts";

export const RESTORE_CONFIRMATION = "RESTORE";
export const RESTORE_USAGE = "Usage: deno task restore <backup.zip>";
const REMOTE_DB_URL_PREFIXES = ["https://", "libsql://"];

export interface RestoreCliDeps extends ScriptIo {
  inspectBackupZip: (data: Uint8Array) => {
    manifest: BackupManifest | null;
    statementCount: number;
  };
  prompt: (message: string) => string | null;
  readFile: (path: string) => Promise<Uint8Array>;
  readRecordedScriptCommit: () => Promise<string>;
  restoreFromZip: (
    data: Uint8Array,
    onProgress: RestoreProgressHandler,
  ) => Promise<void>;
}

/** One stage's progress line: the verb plus how many statements it will run. */
const statementProgress =
  (verb: string) =>
  ({ statementCount }: RestoreProgress): string =>
    `${verb} ${statementCount} SQL statements...`;

const progressMessages: Record<
  RestoreStage,
  (progress: RestoreProgress) => string
> = {
  checking: statementProgress("Checking"),
  "clearing-caches": () => "Clearing cached database data...",
  importing: statementProgress("Importing"),
  rebuilding: () => "Recreating the database schema...",
  resetting: () => "Deleting current database data...",
};

const readRestoreDbUrlOrNull = (deps: RestoreCliDeps): string | null => {
  const dbUrl = deps.getEnv("DB_URL");
  if (!dbUrl?.trim()) {
    deps.stderr("DB_URL is required in .env.");
    return null;
  }
  if (dbUrl === ":memory:") {
    deps.stderr(
      "DB_URL cannot be :memory: for a restore. Set it to the target database in .env.",
    );
    return null;
  }
  if (
    REMOTE_DB_URL_PREFIXES.some((prefix) => dbUrl.startsWith(prefix)) &&
    !deps.getEnv("DB_TOKEN")?.trim()
  ) {
    deps.stderr("DB_TOKEN is required in .env for a remote database.");
    return null;
  }
  const encryptionKey = deps.getEnv("DB_ENCRYPTION_KEY");
  if (!encryptionKey?.trim()) {
    deps.stderr("DB_ENCRYPTION_KEY is required in .env.");
    return null;
  }
  try {
    decodeKeyBytes(encryptionKey);
  } catch (error) {
    deps.stderr(errorMessage(error));
    return null;
  }
  return dbUrl;
};

const writeManifestSummary = (
  manifest: BackupManifest | null,
  statementCount: number,
  write: (line: string) => void,
): boolean => {
  if (manifest === null) {
    write("Backup manifest: not available");
    write(`Backup contents: ${countLabel(statementCount, "SQL statement")}`);
    return false;
  }

  const tableCount = Object.keys(manifest.tables).length;
  const rowCount = sum(Object.values(manifest.tables));
  write(`Backup created: ${manifest.timestamp}`);
  write(
    `Backup contents: ${countLabel(tableCount, "table")}, ` +
      `${countLabel(rowCount, "row")}, ` +
      countLabel(statementCount, "SQL statement"),
  );
  const schemaMismatch = manifest.schemaHash !== SCHEMA_HASH;
  write(
    schemaMismatch
      ? "Schema: does not match this version of the app"
      : "Schema: matches this version of the app",
  );
  return schemaMismatch;
};

export const runRestoreCli = async (deps: RestoreCliDeps): Promise<number> => {
  if (deps.args.length !== 1 || !deps.args[0]?.trim()) {
    deps.stderr(RESTORE_USAGE);
    return 1;
  }

  const path = deps.args[0];
  const dbUrl = readRestoreDbUrlOrNull(deps);
  if (dbUrl === null) return 1;

  deps.stdout(`Reading ${path}...`);
  let data: Uint8Array;
  try {
    data = await deps.readFile(path);
  } catch (error) {
    deps.stderr(`Could not read ${path}: ${errorMessage(error)}`);
    return 1;
  }

  let inspection: ReturnType<RestoreCliDeps["inspectBackupZip"]>;
  try {
    inspection = deps.inspectBackupZip(data);
  } catch (error) {
    deps.stderr(`${path} is not a valid database backup.`);
    deps.stderr(errorMessage(error));
    return 1;
  }

  deps.stdout(`Backup file: ${path} (${formatBytes(data.byteLength)})`);
  deps.stdout(`Target database: ${dbUrl}`);
  const schemaMismatch = writeManifestSummary(
    inspection.manifest,
    inspection.statementCount,
    deps.stdout,
  );
  if (schemaMismatch) {
    deps.stderr(
      "Warning: This backup uses a different database schema. The app may need to apply database updates after the restore.",
    );
  }
  deps.stdout(
    "Warning: This will delete all current data in the target database. This cannot be undone.",
  );

  if (
    deps.prompt(`Type ${RESTORE_CONFIRMATION} to continue:`) !==
    RESTORE_CONFIRMATION
  ) {
    deps.stderr("Restore cancelled. The database was not changed.");
    return 1;
  }

  try {
    await deps.restoreFromZip(data, (progress) =>
      deps.stdout(progressMessages[progress.stage](progress)),
    );
  } catch (error) {
    deps.stderr(`Restore failed: ${errorMessage(error)}`);
    if (error instanceof PostResetError) {
      deps.stderr(
        "The target database was reset before the restore failed. Fix the error and run the restore again.",
      );
    }
    return 1;
  }

  deps.stdout("Database restored successfully.");
  let commit: string;
  try {
    commit = await deps.readRecordedScriptCommit();
  } catch (error) {
    deps.stderr(
      "The database was restored, but its build information could not be read: " +
        errorMessage(error),
    );
    return 1;
  }
  if (/^[0-9a-f]{40}$/.test(commit)) {
    deps.stdout(`The backup was running commit ${commit}.`);
    deps.stdout(
      "Run the restore-deploy workflow with this commit if the app code must also be restored to that point in time.",
    );
  }
  return 0;
};