All files / plainweb/src migrate.ts

82.56% Statements 90/109
71.42% Branches 15/21
100% Functions 4/4
82.56% Lines 90/109

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 1311x 1x   1x 1x   1x 1x 1x   1x   1x 1x       1x 1x 1x 1x 1x 1x 1x   1x 1x 1x 1x 1x 1x       1x         4x 4x 4x 4x 4x       4x 4x 4x 4x 4x 4x 4x 1x 1x 1x 3x 3x 3x 4x       3x 4x           3x 3x 3x   3x 3x       3x 3x 3x   3x 3x 3x 3x   3x   3x       2x 2x 3x 1x 1x 1x 1x       3x   4x 4x 4x 4x 4x 4x 1x 1x 1x 3x 3x 4x 1x 1x 1x 4x 1x 1x 1x 1x 1x 1x 1x  
import fs from "node:fs/promises";
import path from "node:path";
import type { ExpandedPlainwebConfig } from "config";
import { sql } from "drizzle-orm";
import { migrate as drizzleMigrate } from "drizzle-orm/better-sqlite3/migrator";
import type { MigrationConfig } from "drizzle-orm/migrator";
import { getDatabase } from "get-database";
import { getLogger } from "log";
import { directoryExists, fileExists } from "plainweb-fs";
 
const log = getLogger("migrate");
 
export async function migrate(
  config: Pick<
    ExpandedPlainwebConfig<Record<string, unknown>>,
    "database" | "paths" | "nodeEnv"
  >,
) {
  try {
    const migrationConfig = {
      migrationsFolder: config.paths.migrations,
      migrationsTable: config.database.migrationsTable,
      migrationsSchema: config.paths.schema,
    } satisfies MigrationConfig;
 
    log.info("applying migrations...");
    log.debug("using migration config", migrationConfig);
    const db = getDatabase(config);
    drizzleMigrate(db, migrationConfig);
    log.info("migrations have been applied");
  } catch (error) {
    log.error("could not apply migrations, something went wrong");
    throw error;
  }
}
 
/**
 * Return the timestamp of the latest migration in the migrations folder.
 */
async function getLatestLocalMigrationTimestamp(
  config: Pick<ExpandedPlainwebConfig<Record<string, unknown>>, "paths">,
): Promise<number | undefined> {
  log.debug("getting latest local migration timestamp");
  if (!(await directoryExists(config.paths.migrations))) {
    log.debug("migrations directory does not exist yet");
    return undefined;
  }
  const journalPath = path.join(
    config.paths.migrations,
    "meta",
    "_journal.json",
  );
  log.debug("looking for migrations in journal", journalPath);
  if (!(await fileExists(journalPath))) {
    log.warn("no _journal.json file found in migrations folder");
    return undefined;
  }
  const journal = await fs.readFile(journalPath, "utf-8");
  log.debug("journal content", journal);
  const { entries } = JSON.parse(journal);
  if (!entries || entries.length === 0) {
    log.debug("journal found but it's empty");
    return;
  }
  const latestEntry = entries[entries.length - 1];
  if (!latestEntry.when || typeof latestEntry.when !== "number") {
    log.warn(
      "No or invalid 'when' field found in latest migration entry, something seems to be wrong",
    );
    return undefined;
  }
  log.debug("latest local migration timestamp", latestEntry.when);
  return latestEntry.when as number;
}
 
async function getLatestAppliedMigrationTimestamp(
  config: Pick<
    ExpandedPlainwebConfig<Record<string, unknown>>,
    "database" | "paths" | "nodeEnv"
  >,
): Promise<number | undefined> {
  log.debug("getting latest applied migration timestamp");
  const db = getDatabase(config);
 
  try {
    const query = sql`SELECT * FROM ${sql.identifier(
      config.database.migrationsTable,
    )} ORDER BY created_at DESC LIMIT 1`;
 
    const found = db.all<{ created_at: number }>(query);
 
    if (!found[0]) {
      log.debug("no migrations found in database");
      return undefined;
    }
    log.debug("latest migration timestamp", found[0].created_at);
    return found[0].created_at;
  } catch (error) {
    if (error instanceof Error && error.message.includes("no such table")) {
      log.debug("migrations table not found in database");
      return undefined;
    }
    // If it's a different error, we'll rethrow it
    throw error;
  }
}
 
export async function hasPendingMigrations<T extends Record<string, unknown>>(
  config: Pick<ExpandedPlainwebConfig<T>, "database" | "paths" | "nodeEnv">,
): Promise<boolean> {
  log.debug("checking for pending migrations");
  const latestLocalTimestamp = await getLatestLocalMigrationTimestamp(config);
  if (!latestLocalTimestamp) {
    log.debug("no migrations found, no migrations pending");
    return false;
  }
  const latestAppliedTimestamp =
    await getLatestAppliedMigrationTimestamp(config);
  if (!latestAppliedTimestamp) {
    log.debug("no migrations found in database, migrations pending");
    return true;
  }
  if (latestLocalTimestamp > latestAppliedTimestamp) {
    log.debug(
      "local migrations found that have not been applied yet, migrations pending",
    );
    return true;
  }
  return false;
}