import { assert } from "../../../shared/src/asserts.js";
import { must } from "../../../shared/src/must.js";
import * as v from "../../../shared/src/valita.js";
import { disableStatementTimeout, } from "../types/pg.js";
/**
 * Ensures that the schema is compatible with the current code, updating and
 * migrating the schema if necessary.
 */
export async function runSchemaMigrations(log, debugName, schemaName, db, setupMigration, incrementalMigrationMap) {
    log = log.withContext('initSchema', schemaName);
    try {
        const versionMigrations = sorted(incrementalMigrationMap);
        assert(versionMigrations.length, `Must specify at least one version migration`);
        assert(versionMigrations[0][0] > 0, `Versions must be non-zero positive numbers`);
        const codeVersion = versionMigrations[versionMigrations.length - 1][0];
        log.info?.(`Checking schema for compatibility with ${debugName} at schema v${codeVersion}`);
        let versions = await db.begin(async (tx) => {
            const versions = await ensureVersionHistory(tx, schemaName);
            if (codeVersion < versions.minSafeVersion) {
                throw new Error(`Cannot run ${debugName} at schema v${codeVersion} because rollback limit is v${versions.minSafeVersion}`);
            }
            if (versions.dataVersion > codeVersion) {
                log.info?.(`Data is at v${versions.dataVersion}. Resetting to v${codeVersion}`);
                return updateVersionHistory(log, tx, schemaName, versions, codeVersion);
            }
            return versions;
        });
        if (versions.dataVersion < codeVersion) {
            const migrations = versions.dataVersion === 0
                ? // For the empty database v0, only run the setup migration.
                    [[codeVersion, setupMigration]]
                : versionMigrations;
            for (const [dest, migration] of migrations) {
                if (versions.dataVersion < dest) {
                    log.info?.(`Migrating schema from v${versions.dataVersion} to v${dest}`);
                    void log.flush(); // Flush logs before each migration to help debug crash-y migrations.
                    versions = await db.begin(async (tx) => {
                        disableStatementTimeout(tx);
                        // Fetch meta from within the transaction to make the migration atomic.
                        let versions = await ensureVersionHistory(tx, schemaName);
                        if (versions.dataVersion < dest) {
                            versions = await runMigration(log, schemaName, tx, versions, dest, migration);
                            assert(versions.dataVersion === dest);
                        }
                        return versions;
                    });
                }
            }
        }
        assert(versions.dataVersion === codeVersion);
        log.info?.(`Running ${debugName} at schema v${codeVersion}`);
    }
    catch (e) {
        log.error?.('Error in ensureSchemaMigrated', e);
        throw e;
    }
    finally {
        void log.flush(); // Flush the logs but do not block server progress on it.
    }
}
function sorted(incrementalMigrationMap) {
    const versionMigrations = [];
    for (const [v, m] of Object.entries(incrementalMigrationMap)) {
        versionMigrations.push([Number(v), m]);
    }
    return versionMigrations.sort(([a], [b]) => a - b);
}
// Exposed for tests.
export const versionHistory = v.object({
    /**
     * The `schemaVersion` is highest code version that has ever been run
     * on the database, and is used to delineate the structure of the tables
     * in the database. A schemaVersion only moves forward; rolling back to
     * an earlier (safe) code version does not revert schema changes that
     * have already been applied.
     */
    schemaVersion: v.number(),
    /**
     * The data version is the code version of the latest server that ran.
     * Note that this may be less than the schemaVersion in the case that
     * a server is rolled back to an earlier version after a schema change.
     * In such a case, data (but not schema), may need to be re-migrated
     * when rolling forward again.
     */
    dataVersion: v.number(),
    /**
     * The minimum code version that is safe to run. This is used when
     * a schema migration is not backwards compatible with an older version
     * of the code.
     */
    minSafeVersion: v.number(),
});
// Exposed for tests.
export async function createVersionHistoryTable(sql, schemaName) {
    // Note: The `lock` column transparently ensures that at most one row exists.
    await sql `
    CREATE SCHEMA IF NOT EXISTS ${sql(schemaName)};
    CREATE TABLE IF NOT EXISTS ${sql(schemaName)}."versionHistory" (
      "dataVersion" int NOT NULL,
      "schemaVersion" int NOT NULL,
      "minSafeVersion" int NOT NULL,

      lock char(1) NOT NULL CONSTRAINT DF_schema_meta_lock DEFAULT 'v',
      CONSTRAINT PK_schema_meta_lock PRIMARY KEY (lock),
      CONSTRAINT CK_schema_meta_lock CHECK (lock='v')
    );`.simple();
}
async function ensureVersionHistory(sql, schemaName) {
    return must(await getVersionHistory(sql, schemaName, true));
}
export async function getVersionHistory(sql, schemaName, create = false) {
    const exists = await sql `
  SELECT nspname, relname FROM pg_class 
    JOIN pg_namespace ON relnamespace = pg_namespace.oid
    WHERE nspname = ${schemaName} AND relname = ${'versionHistory'}`;
    if (exists.length === 0) {
        if (create) {
            await createVersionHistoryTable(sql, schemaName);
        }
        else {
            return null;
        }
    }
    const rows = await sql `
    SELECT "dataVersion", "schemaVersion", "minSafeVersion" 
       FROM ${sql(schemaName)}."versionHistory"`;
    if (rows.length === 0) {
        return create
            ? { schemaVersion: 0, dataVersion: 0, minSafeVersion: 0 }
            : null;
    }
    return v.parse(rows[0], versionHistory);
}
async function updateVersionHistory(log, sql, schemaName, prev, newVersion, minSafeVersion) {
    assert(newVersion > 0);
    const versions = {
        dataVersion: newVersion,
        // The schemaVersion never moves backwards.
        schemaVersion: Math.max(newVersion, prev.schemaVersion),
        minSafeVersion: getMinSafeVersion(log, prev, minSafeVersion),
    };
    await sql `
    INSERT INTO ${sql(schemaName)}."versionHistory" ${sql(versions)}
      ON CONFLICT (lock) DO UPDATE SET ${sql(versions)}
  `;
    return versions;
}
async function runMigration(log, schemaName, tx, versions, destinationVersion, migration) {
    if (versions.schemaVersion < destinationVersion) {
        await migration.migrateSchema?.(log, tx);
    }
    if (versions.dataVersion < destinationVersion) {
        await migration.migrateData?.(log, tx);
    }
    return updateVersionHistory(log, tx, schemaName, versions, destinationVersion, migration.minSafeVersion);
}
/**
 * Bumps the rollback limit [[toAtLeast]] the specified version.
 * Leaves the rollback limit unchanged if it is equal or greater.
 */
function getMinSafeVersion(log, current, proposedSafeVersion) {
    if (proposedSafeVersion === undefined) {
        return current.minSafeVersion;
    }
    if (current.minSafeVersion >= proposedSafeVersion) {
        // The rollback limit must never move backwards.
        log.debug?.(`rollback limit is already at ${current.minSafeVersion}, ` +
            `don't need to bump to ${proposedSafeVersion}`);
        return current.minSafeVersion;
    }
    log.info?.(`bumping rollback limit from ${current.minSafeVersion} to ${proposedSafeVersion}`);
    return proposedSafeVersion;
}
//# sourceMappingURL=migration.js.map