"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
    return function (target, key) { decorator(target, key, paramIndex); }
};
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
var DatabaseRepository_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.DatabaseRepository = exports.probes = exports.cachedVectorExtension = void 0;
exports.getVectorExtension = getVectorExtension;
const sql_tools_1 = require("@immich/sql-tools");
const common_1 = require("@nestjs/common");
const async_lock_1 = __importDefault(require("async-lock"));
const kysely_1 = require("kysely");
const nestjs_kysely_1 = require("nestjs-kysely");
const promises_1 = require("node:fs/promises");
const node_path_1 = require("node:path");
const semver_1 = __importDefault(require("semver"));
const constants_1 = require("../constants");
const decorators_1 = require("../decorators");
const enum_1 = require("../enum");
const config_repository_1 = require("./config.repository");
const logging_repository_1 = require("./logging.repository");
require("../schema");
const functions_1 = require("../schema/functions");
const database_1 = require("../utils/database");
const validation_1 = require("../validation");
async function getVectorExtension(runner) {
    if (exports.cachedVectorExtension) {
        return exports.cachedVectorExtension;
    }
    exports.cachedVectorExtension = new config_repository_1.ConfigRepository().getEnv().database.vectorExtension;
    if (exports.cachedVectorExtension) {
        return exports.cachedVectorExtension;
    }
    const query = `SELECT name FROM pg_available_extensions WHERE name IN (${constants_1.VECTOR_EXTENSIONS.map((ext) => `'${ext}'`).join(', ')})`;
    const { rows: availableExtensions } = await kysely_1.sql.raw(query).execute(runner);
    const extensionNames = new Set(availableExtensions.map((row) => row.name));
    exports.cachedVectorExtension = constants_1.VECTOR_EXTENSIONS.find((ext) => extensionNames.has(ext));
    if (!exports.cachedVectorExtension) {
        throw new Error(`No vector extension found. Available extensions: ${constants_1.VECTOR_EXTENSIONS.join(', ')}`);
    }
    return exports.cachedVectorExtension;
}
exports.probes = {
    [enum_1.VectorIndex.Clip]: 1,
    [enum_1.VectorIndex.Face]: 1,
};
let DatabaseRepository = DatabaseRepository_1 = class DatabaseRepository {
    db;
    logger;
    configRepository;
    asyncLock = new async_lock_1.default();
    constructor(db, logger, configRepository) {
        this.db = db;
        this.logger = logger;
        this.configRepository = configRepository;
        this.logger.setContext(DatabaseRepository_1.name);
    }
    async shutdown() {
        await this.db.destroy();
    }
    getVectorExtension() {
        return getVectorExtension(this.db);
    }
    async getExtensionVersions(extensions) {
        const { rows } = await (0, kysely_1.sql) `
      SELECT name, default_version as "availableVersion", installed_version as "installedVersion"
      FROM pg_available_extensions
      WHERE name in (${kysely_1.sql.join(extensions)})
    `.execute(this.db);
        return rows;
    }
    getExtensionVersionRange(extension) {
        switch (extension) {
            case enum_1.DatabaseExtension.VectorChord: {
                return constants_1.VECTORCHORD_VERSION_RANGE;
            }
            case enum_1.DatabaseExtension.Vectors: {
                return constants_1.VECTORS_VERSION_RANGE;
            }
            case enum_1.DatabaseExtension.Vector: {
                return constants_1.VECTOR_VERSION_RANGE;
            }
            default: {
                throw new Error(`Unsupported vector extension: '${extension}'`);
            }
        }
    }
    async getPostgresVersion() {
        const { rows } = await (0, kysely_1.sql) `SHOW server_version`.execute(this.db);
        return rows[0].server_version;
    }
    getPostgresVersionRange() {
        return constants_1.POSTGRES_VERSION_RANGE;
    }
    async createExtension(extension) {
        this.logger.log(`Creating ${constants_1.EXTENSION_NAMES[extension]} extension`);
        await (0, kysely_1.sql) `CREATE EXTENSION IF NOT EXISTS ${kysely_1.sql.raw(extension)} CASCADE`.execute(this.db);
        if (extension === enum_1.DatabaseExtension.VectorChord) {
            const dbName = kysely_1.sql.id(await this.getDatabaseName());
            await (0, kysely_1.sql) `ALTER DATABASE ${dbName} SET vchordrq.probes = 1`.execute(this.db);
            await (0, kysely_1.sql) `SET vchordrq.probes = 1`.execute(this.db);
        }
    }
    async dropExtension(extension) {
        this.logger.log(`Dropping ${constants_1.EXTENSION_NAMES[extension]} extension`);
        await (0, kysely_1.sql) `DROP EXTENSION IF EXISTS ${kysely_1.sql.raw(extension)}`.execute(this.db);
    }
    async updateVectorExtension(extension, targetVersion) {
        const [{ availableVersion, installedVersion }] = await this.getExtensionVersions([extension]);
        if (!installedVersion) {
            throw new Error(`${constants_1.EXTENSION_NAMES[extension]} extension is not installed`);
        }
        if (!availableVersion) {
            throw new Error(`No available version for ${constants_1.EXTENSION_NAMES[extension]} extension`);
        }
        targetVersion ??= availableVersion;
        let restartRequired = false;
        const diff = semver_1.default.diff(installedVersion, targetVersion);
        if (!diff) {
            return { restartRequired: false };
        }
        await Promise.all([
            this.db.schema.dropIndex(enum_1.VectorIndex.Clip).ifExists().execute(),
            this.db.schema.dropIndex(enum_1.VectorIndex.Face).ifExists().execute(),
        ]);
        await this.db.transaction().execute(async (tx) => {
            await this.setSearchPath(tx);
            await (0, kysely_1.sql) `ALTER EXTENSION ${kysely_1.sql.raw(extension)} UPDATE TO ${kysely_1.sql.lit(targetVersion)}`.execute(tx);
            if (extension === enum_1.DatabaseExtension.Vectors && (diff === 'major' || diff === 'minor')) {
                await (0, kysely_1.sql) `SELECT pgvectors_upgrade()`.execute(tx);
                restartRequired = true;
            }
        });
        if (!restartRequired) {
            await Promise.all([this.reindexVectors(enum_1.VectorIndex.Clip), this.reindexVectors(enum_1.VectorIndex.Face)]);
        }
        return { restartRequired };
    }
    async prewarm(index) {
        const vectorExtension = await getVectorExtension(this.db);
        if (vectorExtension !== enum_1.DatabaseExtension.VectorChord) {
            return;
        }
        this.logger.debug(`Prewarming ${index}`);
        await (0, kysely_1.sql) `SELECT vchordrq_prewarm(${index})`.execute(this.db);
    }
    async reindexVectorsIfNeeded(names) {
        const { rows } = await (0, kysely_1.sql) `SELECT indexdef, indexname FROM pg_indexes WHERE indexname = ANY(ARRAY[${kysely_1.sql.join(names)}])`.execute(this.db);
        const vectorExtension = await getVectorExtension(this.db);
        const promises = [];
        for (const indexName of names) {
            const row = rows.find((index) => index.indexname === indexName);
            const table = constants_1.VECTOR_INDEX_TABLES[indexName];
            if (!row) {
                promises.push(this.reindexVectors(indexName));
                continue;
            }
            switch (vectorExtension) {
                case enum_1.DatabaseExtension.Vector: {
                    if (!row.indexdef.toLowerCase().includes('using hnsw')) {
                        promises.push(this.reindexVectors(indexName));
                    }
                    break;
                }
                case enum_1.DatabaseExtension.Vectors: {
                    if (!row.indexdef.toLowerCase().includes('using vectors')) {
                        promises.push(this.reindexVectors(indexName));
                    }
                    break;
                }
                case enum_1.DatabaseExtension.VectorChord: {
                    const matches = row.indexdef.match(/(?<=lists = \[)\d+/g);
                    const lists = matches && matches.length > 0 ? Number(matches[0]) : 1;
                    promises.push(this.getRowCount(table).then((count) => {
                        const targetLists = this.targetListCount(count);
                        this.logger.log(`targetLists=${targetLists}, current=${lists} for ${indexName} of ${count} rows`);
                        if (!row.indexdef.toLowerCase().includes('using vchordrq') ||
                            (lists !== targetLists && lists !== this.targetListCount(count * constants_1.VECTORCHORD_LIST_SLACK_FACTOR))) {
                            exports.probes[indexName] = this.targetProbeCount(targetLists);
                            return this.reindexVectors(indexName, { lists: targetLists });
                        }
                        else {
                            exports.probes[indexName] = this.targetProbeCount(lists);
                        }
                    }));
                    break;
                }
            }
        }
        if (promises.length > 0) {
            await Promise.all(promises);
        }
    }
    async reindexVectors(indexName, { lists } = {}) {
        this.logger.log(`Reindexing ${indexName} (This may take a while, do not restart)`);
        const table = constants_1.VECTOR_INDEX_TABLES[indexName];
        const vectorExtension = await getVectorExtension(this.db);
        const { rows } = await (0, kysely_1.sql) `SELECT column_name as "columnName" FROM information_schema.columns WHERE table_name = ${table}`.execute(this.db);
        if (rows.length === 0) {
            this.logger.warn(`Table ${table} does not exist, skipping reindexing. This is only normal if this is a new Immich instance.`);
            return;
        }
        const dimSize = await this.getDimensionSize(table);
        lists ||= this.targetListCount(await this.getRowCount(table));
        await this.db.transaction().execute(async (tx) => {
            await (0, kysely_1.sql) `DROP INDEX IF EXISTS ${kysely_1.sql.raw(indexName)}`.execute(tx);
            if (table === 'smart_search') {
                await (0, kysely_1.sql) `ALTER TABLE ${kysely_1.sql.raw(table)} DROP CONSTRAINT IF EXISTS dim_size_constraint`.execute(tx);
            }
            if (!rows.some((row) => row.columnName === 'embedding')) {
                this.logger.warn(`Column 'embedding' does not exist in table '${table}', truncating and adding column.`);
                await (0, kysely_1.sql) `TRUNCATE TABLE ${kysely_1.sql.raw(table)}`.execute(tx);
                await (0, kysely_1.sql) `ALTER TABLE ${kysely_1.sql.raw(table)} ADD COLUMN embedding real[] NOT NULL`.execute(tx);
            }
            await (0, kysely_1.sql) `ALTER TABLE ${kysely_1.sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE real[]`.execute(tx);
            const schema = vectorExtension === enum_1.DatabaseExtension.Vectors ? 'vectors.' : '';
            await (0, kysely_1.sql) `
        ALTER TABLE ${kysely_1.sql.raw(table)}
        ALTER COLUMN embedding
        SET DATA TYPE ${kysely_1.sql.raw(schema)}vector(${kysely_1.sql.raw(String(dimSize))})`.execute(tx);
            await kysely_1.sql.raw((0, database_1.vectorIndexQuery)({ vectorExtension, table, indexName, lists })).execute(tx);
        });
        try {
            await (0, kysely_1.sql) `VACUUM ANALYZE ${kysely_1.sql.raw(table)}`.execute(this.db);
        }
        catch (error) {
            this.logger.warn(`Failed to vacuum table '${table}'. The DB will temporarily use more disk space: ${error}`);
        }
        this.logger.log(`Reindexed ${indexName}`);
    }
    async setSearchPath(tx) {
        await (0, kysely_1.sql) `SET search_path TO "$user", public, vectors`.execute(tx);
    }
    async getDatabaseName() {
        const { rows } = await (0, kysely_1.sql) `SELECT current_database() as db`.execute(this.db);
        return rows[0].db;
    }
    getMigrations() {
        return this.db.selectFrom('kysely_migrations').select(['name', 'timestamp']).orderBy('name', 'asc').execute();
    }
    async getSchemaDrift() {
        const source = (0, sql_tools_1.schemaFromCode)({
            overrides: true,
            namingStrategy: 'default',
            uuidFunction: (version) => (version === 7 ? `${functions_1.immich_uuid_v7.name}()` : 'uuid_generate_v4()'),
        });
        const { database } = this.configRepository.getEnv();
        const target = await (0, sql_tools_1.schemaFromDatabase)({ connection: database.config });
        const drift = (0, sql_tools_1.schemaDiff)(source, target, {
            tables: { ignoreExtra: true },
            constraints: { ignoreExtra: false },
            indexes: { ignoreExtra: true },
            triggers: { ignoreExtra: true },
            columns: { ignoreExtra: true },
            functions: { ignoreExtra: false },
            parameters: { ignoreExtra: true },
        });
        return drift;
    }
    async getDimensionSize(table, column = 'embedding') {
        const { rows } = await (0, kysely_1.sql) `
      SELECT atttypmod as dimsize
      FROM pg_attribute f
        JOIN pg_class c ON c.oid = f.attrelid
      WHERE c.relkind = 'r'::char
        AND f.attnum > 0
        AND c.relname = ${table}::text
        AND f.attname = ${column}::text
    `.execute(this.db);
        const dimSize = rows[0]?.dimsize;
        if (!(0, validation_1.isValidInteger)(dimSize, { min: 1, max: 2 ** 16 })) {
            this.logger.warn(`Could not retrieve dimension size of column '${column}' in table '${table}', assuming 512`);
            return 512;
        }
        return dimSize;
    }
    async setDimensionSize(dimSize) {
        if (!(0, validation_1.isValidInteger)(dimSize, { min: 1, max: 2 ** 16 })) {
            throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
        }
        await this.db.transaction().execute(async (trx) => {
            await (0, kysely_1.sql) `delete from ${kysely_1.sql.table('smart_search')}`.execute(trx);
            await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute();
            await (0, kysely_1.sql) `alter table ${kysely_1.sql.table('smart_search')} add constraint dim_size_constraint check (array_length(embedding::real[], 1) = ${kysely_1.sql.lit(dimSize)})`.execute(trx);
        });
        const vectorExtension = await this.getVectorExtension();
        await this.db.transaction().execute(async (trx) => {
            await (0, kysely_1.sql) `drop index if exists clip_index`.execute(trx);
            await trx.schema
                .alterTable('smart_search')
                .alterColumn('embedding', (col) => col.setDataType(kysely_1.sql.raw(`vector(${dimSize})`)))
                .execute();
            await kysely_1.sql
                .raw((0, database_1.vectorIndexQuery)({ vectorExtension, table: 'smart_search', indexName: enum_1.VectorIndex.Clip }))
                .execute(trx);
            await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute();
        });
        exports.probes[enum_1.VectorIndex.Clip] = 1;
        await (0, kysely_1.sql) `vacuum analyze ${kysely_1.sql.table('smart_search')}`.execute(this.db);
    }
    async deleteAllSearchEmbeddings() {
        await (0, kysely_1.sql) `truncate ${kysely_1.sql.table('smart_search')}`.execute(this.db);
    }
    targetListCount(count) {
        if (count < 128_000) {
            return 1;
        }
        else if (count < 2_048_000) {
            return 1 << (32 - Math.clz32(count / 1000));
        }
        else {
            return 1 << (33 - Math.clz32(Math.sqrt(count)));
        }
    }
    targetProbeCount(lists) {
        return Math.ceil(lists / 8);
    }
    async getRowCount(table) {
        const { count } = await this.db
            .selectFrom(this.db.dynamic.table(table).as('t'))
            .select((eb) => eb.fn.countAll().as('count'))
            .executeTakeFirstOrThrow();
        return count;
    }
    async runMigrations() {
        this.logger.log('Running migrations');
        const migrator = this.createMigrator();
        const { error, results } = await migrator.migrateToLatest();
        for (const result of results ?? []) {
            if (result.status === 'Success') {
                this.logger.log(`Migration "${result.migrationName}" succeeded`);
            }
            if (result.status === 'Error') {
                this.logger.warn(`Migration "${result.migrationName}" failed`);
            }
        }
        if (error) {
            this.logger.error(`Migrations failed: ${error}`);
            throw error;
        }
        this.logger.log('Finished running migrations');
    }
    async migrateFilePaths(sourceFolder, targetFolder) {
        if (sourceFolder.endsWith('/')) {
            sourceFolder = sourceFolder.slice(0, -1);
        }
        if (targetFolder.endsWith('/')) {
            targetFolder = targetFolder.slice(0, -1);
        }
        const sourceRegex = '^' + sourceFolder.replaceAll(/[-[\]{}()*+?.,\\^$|#\s]/g, String.raw `\$&`);
        const source = kysely_1.sql.raw(`'${sourceRegex}'`);
        const target = kysely_1.sql.lit(targetFolder);
        await this.db.transaction().execute(async (tx) => {
            await tx
                .updateTable('asset')
                .set((eb) => ({
                originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
            }))
                .execute();
            await tx
                .updateTable('asset_file')
                .set((eb) => ({ path: eb.fn('REGEXP_REPLACE', ['path', source, target]) }))
                .execute();
            await tx
                .updateTable('person')
                .set((eb) => ({ thumbnailPath: eb.fn('REGEXP_REPLACE', ['thumbnailPath', source, target]) }))
                .execute();
            await tx
                .updateTable('user')
                .set((eb) => ({ profileImagePath: eb.fn('REGEXP_REPLACE', ['profileImagePath', source, target]) }))
                .execute();
        });
    }
    async withLock(lock, callback) {
        let res;
        await this.asyncLock.acquire(enum_1.DatabaseLock[lock], async () => {
            await this.db.connection().execute(async (connection) => {
                try {
                    await this.acquireLock(lock, connection);
                    res = await callback();
                }
                finally {
                    await this.releaseLock(lock, connection);
                }
            });
        });
        return res;
    }
    tryLock(lock) {
        return this.db.connection().execute(async (connection) => this.acquireTryLock(lock, connection));
    }
    isBusy(lock) {
        return this.asyncLock.isBusy(enum_1.DatabaseLock[lock]);
    }
    async wait(lock) {
        await this.asyncLock.acquire(enum_1.DatabaseLock[lock], () => { });
    }
    async acquireLock(lock, connection) {
        await (0, kysely_1.sql) `SELECT pg_advisory_lock(${lock})`.execute(connection);
    }
    async acquireTryLock(lock, connection) {
        const { rows } = await (0, kysely_1.sql) `SELECT pg_try_advisory_lock(${lock})`.execute(connection);
        return rows[0].pg_try_advisory_lock;
    }
    async releaseLock(lock, connection) {
        await (0, kysely_1.sql) `SELECT pg_advisory_unlock(${lock})`.execute(connection);
    }
    async revertLastMigration() {
        this.logger.debug('Reverting last migration');
        const migrator = this.createMigrator();
        const { error, results } = await migrator.migrateDown();
        for (const result of results ?? []) {
            if (result.status === 'Success') {
                this.logger.log(`Reverted migration "${result.migrationName}"`);
            }
            if (result.status === 'Error') {
                this.logger.warn(`Failed to revert migration "${result.migrationName}"`);
            }
        }
        if (error) {
            this.logger.error(`Failed to revert migrations: ${error}`);
            throw error;
        }
        const reverted = results?.find((result) => result.direction === 'Down' && result.status === 'Success');
        if (!reverted) {
            this.logger.debug('No migrations to revert');
            return undefined;
        }
        this.logger.debug('Finished reverting migration');
        return reverted.migrationName;
    }
    createMigrator() {
        return new kysely_1.Migrator({
            db: this.db,
            migrationLockTableName: 'kysely_migrations_lock',
            allowUnorderedMigrations: this.configRepository.isDev(),
            migrationTableName: 'kysely_migrations',
            provider: new kysely_1.FileMigrationProvider({
                fs: { readdir: promises_1.readdir },
                path: { join: node_path_1.join },
                migrationFolder: (0, node_path_1.join)(__dirname, '..', 'schema/migrations'),
            }),
        });
    }
};
exports.DatabaseRepository = DatabaseRepository;
__decorate([
    (0, decorators_1.GenerateSql)({ params: [[enum_1.DatabaseExtension.Vectors]] }),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [Array]),
    __metadata("design:returntype", Promise)
], DatabaseRepository.prototype, "getExtensionVersions", null);
__decorate([
    (0, decorators_1.GenerateSql)(),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", []),
    __metadata("design:returntype", Promise)
], DatabaseRepository.prototype, "getPostgresVersion", null);
exports.DatabaseRepository = DatabaseRepository = DatabaseRepository_1 = __decorate([
    (0, common_1.Injectable)(),
    __param(0, (0, nestjs_kysely_1.InjectKysely)()),
    __metadata("design:paramtypes", [kysely_1.Kysely,
        logging_repository_1.LoggingRepository,
        config_repository_1.ConfigRepository])
], DatabaseRepository);
//# sourceMappingURL=database.repository.js.map