Ctrl K
ComunidadGuías…

scripts/turso-migration-steps.ts

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

Archivo original ↗Preguntar a KiBot
import { join } from "@std/path";
import {
  readSnapshotRequest,
  type SnapshotProgressWriter,
  type SnapshotRequest,
} from "#scripts/database-snapshot-lib.ts";
import { taskWithoutArguments } from "#scripts/no-argument-task.ts";
import type { ScriptIo } from "#scripts/script-runner.ts";
import { tursoDatabaseSlug } from "#shared/config.ts";
import { errorMessage } from "#shared/error-message.ts";
import { requireSuccess } from "#shared/result.ts";
import type { TursoApi, TursoDatabaseCredentials } from "#shared/turso-api.ts";

/** Everything a migration needs from the outside world, so tests can stand in. */
export interface TursoMigrationDeps extends ScriptIo {
  createApi: (apiToken: string, signal: AbortSignal) => TursoApi;
  createSnapshot: (
    request: SnapshotRequest,
    writeProgress: SnapshotProgressWriter,
    signal: AbortSignal,
  ) => Promise<string>;
  makeTempDir: () => Promise<string>;
  prompt: (message: string) => string | null;
  promptSecret: (message: string) => string | null;
  removeTempDir: (path: string) => Promise<void>;
  signal: AbortSignal;
  uploadDatabaseFile: (
    path: string,
    credentials: TursoDatabaseCredentials,
    signal: AbortSignal,
  ) => Promise<void>;
  verifyUploadFile: (path: string, signal: AbortSignal) => Promise<void>;
}

/** Where a migrated database will live. */
export interface TursoTarget {
  group: string;
  organization: string;
}

export interface MigrationOutcome {
  cleanupError: unknown | null;
  credentials: TursoDatabaseCredentials;
  tempDirectory: string;
}

/** Thrown when the person running the task presses Ctrl-D at a question. */
export class MigrationCancelled extends Error {}

/** Read an answer that must be there, or stop. */
export const requiredAnswer = (value: string | null, label: string): string => {
  if (value === null) throw new MigrationCancelled("Migration cancelled.");
  const answer = value.trim();
  if (!answer) throw new Error(`${label} is required.`);
  return answer;
};

/** Read a setting from the environment, treating blank as missing. */
export const configuredValue = (
  deps: Pick<TursoMigrationDeps, "getEnv">,
  key: string,
): string | null => {
  const value = deps.getEnv(key)?.trim();
  return value ? value : null;
};

/** Read a setting, asking for it when it is not already configured. */
export const configuredOrAsked = (
  deps: Pick<TursoMigrationDeps, "getEnv" | "promptSecret">,
  key: string,
  question: string,
): string =>
  configuredValue(deps, key) ??
  requiredAnswer(deps.promptSecret(question), question);

/** Pick a Turso organization or group, using the configured one when set. */
export const chooseTursoName = (
  deps: Pick<TursoMigrationDeps, "getEnv" | "prompt">,
  kind: "group" | "organization",
  configured: string | null,
  available: string[],
): string => {
  if (available.length === 0)
    throw new Error(`No Turso ${kind}s are available.`);
  if (configured !== null) {
    if (!available.includes(configured)) {
      throw new Error(
        `TURSO_${kind.toUpperCase()} is not available: ${configured}`,
      );
    }
    return configured;
  }
  const [onlyChoice] = available;
  if (available.length === 1 && onlyChoice !== undefined) return onlyChoice;
  const answer = requiredAnswer(
    deps.prompt(`Turso ${kind} (${available.join(", ")}):`),
    `Turso ${kind}`,
  );
  if (!available.includes(answer)) {
    throw new Error(`Turso ${kind} must be one of: ${available.join(", ")}.`);
  }
  return answer;
};

/** Find the organization and group new databases should be created in. */
const resolveTursoTarget = async (
  deps: TursoMigrationDeps,
  api: TursoApi,
): Promise<TursoTarget> => {
  const organization = chooseTursoName(
    deps,
    "organization",
    configuredValue(deps, "TURSO_ORGANIZATION"),
    requireSuccess(await api.listOrganizations()),
  );
  deps.signal.throwIfAborted();
  const group = chooseTursoName(
    deps,
    "group",
    configuredValue(deps, "TURSO_GROUP"),
    requireSuccess(await api.listGroups(organization)),
  );
  deps.signal.throwIfAborted();
  return { group, organization };
};

const deleteIncompleteDatabase = async (
  api: TursoApi,
  organization: string,
  credentials: TursoDatabaseCredentials,
  uploadError: unknown,
): Promise<never> => {
  try {
    requireSuccess(await api.deleteDatabase(organization, credentials.name));
  } catch (cleanupError) {
    throw new Error(
      `${errorMessage(uploadError)}. Cleanup also failed: ${errorMessage(
        cleanupError,
      )}`,
    );
  }
  throw uploadError;
};

/** Copy one database into a brand new Turso database, through a temporary file. */
const migrateSnapshot = async (
  deps: TursoMigrationDeps,
  api: TursoApi,
  source: Omit<SnapshotRequest, "outputPath">,
  target: TursoTarget,
  name: string,
): Promise<MigrationOutcome> => {
  const tempDirectory = await deps.makeTempDir();
  let credentials: TursoDatabaseCredentials;
  try {
    deps.signal.throwIfAborted();
    deps.stdout("Downloading the source database...");
    const path = await deps.createSnapshot(
      { ...source, outputPath: join(tempDirectory, "database.sqlite") },
      deps.stdout,
      deps.signal,
    );
    deps.signal.throwIfAborted();
    deps.stdout("Checking the SQLite file for Turso...");
    await deps.verifyUploadFile(path, deps.signal);
    deps.signal.throwIfAborted();
    deps.stdout("Creating the Turso database...");
    credentials = requireSuccess(
      await api.createDatabase({
        group: target.group,
        name,
        organization: target.organization,
        seed: "database_upload",
      }),
    );
    deps.stdout("Uploading the SQLite file...");
    try {
      deps.signal.throwIfAborted();
      await deps.uploadDatabaseFile(path, credentials, deps.signal);
      deps.signal.throwIfAborted();
    } catch (error) {
      return await deleteIncompleteDatabase(
        api,
        target.organization,
        credentials,
        error,
      );
    }
  } catch (error) {
    try {
      await deps.removeTempDir(tempDirectory);
    } catch (cleanupError) {
      throw new Error(
        `${errorMessage(error)}. Temporary files could not be removed: ${errorMessage(
          cleanupError,
        )}. Remove this directory: ${tempDirectory}`,
      );
    }
    throw error;
  }

  let cleanupError: unknown | null = null;
  try {
    await deps.removeTempDir(tempDirectory);
  } catch (error) {
    cleanupError = error;
  }
  return { cleanupError, credentials, tempDirectory };
};

/**
 * Find a Turso name nobody is using. A name that is taken is a conflict only
 * the person running this can settle, so they are asked for another one rather
 * than the migration guessing or stopping.
 */
const freeDatabaseName = async (
  deps: TursoMigrationDeps,
  api: TursoApi,
  organization: string,
  wanted: string,
): Promise<string> => {
  let name = wanted;
  while (requireSuccess(await api.databaseExists(organization, name))) {
    deps.stdout(`Turso database already exists: ${organization}/${name}`);
    name = tursoDatabaseSlug(
      requiredAnswer(
        deps.prompt("Choose another Turso database name:"),
        "Turso database name",
      ),
    );
    deps.signal.throwIfAborted();
  }
  return name;
};

/** Where a database is being copied from. */
export interface MigrationSource {
  dbToken: string;
  dbUrl: string;
}

/** Check a source address and token before anything else happens. */
export const checkedSource = (source: MigrationSource): MigrationSource => {
  const sourceEnv: Record<string, string> = {
    DB_TOKEN: source.dbToken,
    DB_URL: source.dbUrl,
  };
  const checked = readSnapshotRequest(
    { outputPath: "database.sqlite" },
    (key) => sourceEnv[key],
  );
  return { dbToken: checked.dbToken, dbUrl: checked.dbUrl };
};

/**
 * Check the source address, claim the Turso name, copy the database over, and
 * print the new credentials. This is the whole migration every task shares.
 */
export const migrateDatabase = async (
  deps: TursoMigrationDeps,
  api: TursoApi,
  source: MigrationSource,
  wanted: string,
): Promise<MigrationOutcome> => {
  const checked = checkedSource(source);
  deps.stdout("Checking the Turso account...");
  const target = await resolveTursoTarget(deps, api);
  const name = await freeDatabaseName(deps, api, target.organization, wanted);
  deps.stdout(`Destination database: ${target.organization}/${name}`);
  deps.stdout(`Turso group: ${target.group}`);
  const outcome = await migrateSnapshot(deps, api, checked, target, name);
  deps.stdout("Database migrated to Turso.");
  deps.stdout(`DB_URL=${outcome.credentials.dbUrl}`);
  deps.stdout(`DB_TOKEN=${outcome.credentials.dbToken}`);
  return outcome;
};

/**
 * Run one migration task: refuse arguments, then turn any failure into a
 * console message and an exit code.
 */
export const runMigrationTask = taskWithoutArguments<TursoMigrationDeps>(
  (deps, error) => reportMigrationFailure(deps, error),
);

/** Report leftover temporary files, and say whether the task should fail. */
export const reportCleanupError = (
  deps: Pick<TursoMigrationDeps, "stderr">,
  outcome: MigrationOutcome,
): boolean => {
  if (outcome.cleanupError === null) return false;
  deps.stderr(
    `The database was migrated, but temporary files could not be removed: ${errorMessage(
      outcome.cleanupError,
    )}`,
  );
  deps.stderr(`Remove this directory: ${outcome.tempDirectory}`);
  return true;
};

/** Turn a failure into a console message and an exit code. */
export const reportMigrationFailure = (
  deps: Pick<TursoMigrationDeps, "signal" | "stderr">,
  error: unknown,
): number => {
  if (deps.signal.aborted) {
    const message = errorMessage(error);
    deps.stderr(
      message === "Migration interrupted"
        ? "Migration interrupted."
        : `Migration interrupted: ${message}`,
    );
    return 130;
  }
  deps.stderr(
    error instanceof MigrationCancelled
      ? error.message
      : `Migration failed: ${errorMessage(error)}`,
  );
  return 1;
};