/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
/** @packageDocumentation
 * @module SQLiteDb
 */
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
    if (value !== null && value !== void 0) {
        if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
        var dispose, inner;
        if (async) {
            if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
            dispose = value[Symbol.asyncDispose];
        }
        if (dispose === void 0) {
            if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
            dispose = value[Symbol.dispose];
            if (async) inner = dispose;
        }
        if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
        if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
        env.stack.push({ value: value, dispose: dispose, async: async });
    }
    else if (async) {
        env.stack.push({ async: true });
    }
    return value;
};
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
    return function (env) {
        function fail(e) {
            env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
            env.hasError = true;
        }
        var r, s = 0;
        function next() {
            while (r = env.stack.pop()) {
                try {
                    if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
                    if (r.dispose) {
                        var result = r.dispose.call(r.value);
                        if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
                    }
                    else s |= 1;
                }
                catch (e) {
                    fail(e);
                }
            }
            if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
            if (env.hasError) throw env.error;
        }
        return next();
    };
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
    var e = new Error(message);
    return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
});
import * as fs from "fs";
import { dirname } from "path";
import * as semver from "semver";
import { OpenMode } from "@itwin/core-bentley";
import { SqliteError } from "@itwin/core-common";
import { CloudSqlite } from "./CloudSqlite";
import { IModelNative } from "./internal/NativePlatform";
import { IModelJsFs } from "./IModelJsFs";
import { SqliteStatement, StatementCache } from "./SqliteStatement";
import { _nativeDb } from "./internal/Symbols";
// cspell:ignore savepoint julianday rowid
/* eslint-disable @typescript-eslint/unified-signatures */
/**
 * A "generic" SQLiteDb. This class may be used to access local files or databases in a cloud container.
 * @public
 */
export class SQLiteDb {
    /** @internal */
    [_nativeDb] = new IModelNative.platform.SQLiteDb();
    _sqliteStatementCache = new StatementCache();
    /** @internal */
    static createBlobIO() {
        return new IModelNative.platform.BlobIO();
    }
    /** alias for closeDb.
     * @deprecated in 4.0 - will not be removed until after 2026-06-13. Use [[closeDb]]
     */
    dispose() {
        this.closeDb();
    }
    createDb(dbName, container, params) {
        this[_nativeDb].createDb(dbName, container, params);
    }
    openDb(dbName, openMode, container) {
        this[_nativeDb].openDb(dbName, openMode, container);
    }
    /** Close SQLiteDb.
     * @param saveChanges if true, call `saveChanges` before closing db. Otherwise unsaved changes are abandoned.
     */
    closeDb(saveChanges) {
        if (saveChanges && this.isOpen)
            this.saveChanges();
        this._sqliteStatementCache.clear();
        this[_nativeDb].closeDb();
    }
    /** Returns true if this SQLiteDb is open */
    get isOpen() { return this[_nativeDb].isOpen(); }
    /** Returns true if this SQLiteDb is open readonly */
    get isReadonly() { return this[_nativeDb].isReadonly(); }
    /** Create a new table in this database. */
    createTable(args) {
        const timestampCol = args.addTimestamp ? ",lastMod TIMESTAMP NOT NULL DEFAULT(julianday('now'))" : "";
        const constraints = args.constraints ? `,${args.constraints}` : "";
        this.executeSQL(`CREATE TABLE ${args.tableName}(${args.columns}${timestampCol}${constraints})`);
        if (args.addTimestamp)
            this.executeSQL(`CREATE TRIGGER ${args.tableName}_timestamp AFTER UPDATE ON ${args.tableName} WHEN old.lastMod=new.lastMod AND old.lastMod != julianday('now') BEGIN UPDATE ${args.tableName} SET lastMod=julianday('now') WHERE rowid=new.rowid; END`);
    }
    /**
     * Get the last modified date for a row in a table of this database.
     * @note the table must have been created with `addTimestamp: true`
    */
    readLastModTime(tableName, rowId) {
        return this.withSqliteStatement(`SELECT lastMod from ${tableName} WHERE rowid=?`, (stmt) => {
            stmt.bindInteger(1, rowId);
            return stmt.getValueDate(0);
        });
    }
    /**
     * Open a database, perform an operation, then close the database.
     *
     * Details:
     * - if database is open, throw an error
     * - open a database
     * - call a function with the database opened. If it is async, await its return.
     * - if function throws, abandon all changes, close database, and rethrow
     * - save all changes
     * - close the database
     * @return value from operation
     */
    withOpenDb(args, operation) {
        if (this.isOpen)
            SqliteError.throwError("already-open", "database is already open", args.dbName);
        const save = () => this.closeDb(true), abandon = () => this.closeDb(false);
        this.openDb(args.dbName, args.openMode ?? OpenMode.Readonly, args.container);
        try {
            const result = operation();
            result instanceof Promise ? result.then(save, abandon) : save();
            return result;
        }
        catch (e) {
            abandon();
            throw e;
        }
    }
    /** The cloud container backing this SQLite database, if any.
     * @beta
     */
    get cloudContainer() {
        return this[_nativeDb].cloudContainer;
    }
    /** Returns the Id of the most-recently-inserted row in this database, per [sqlite3_last_insert_rowid](https://www.sqlite.org/c3ref/last_insert_rowid.html). */
    getLastInsertRowId() {
        return this[_nativeDb].getLastInsertRowId();
    }
    /**
     * Perform an operation on a database in a CloudContainer with the write lock held.
     *
     * Details:
     * - acquire the write lock on a CloudContainer
     * - call `withOpenDb` with openMode `ReadWrite`
     * - upload changes
     * - release the write lock
     * @param args arguments to lock the container and open the database
     * @param operation an operation performed on the database with the write lock held.
     * @return value from operation
     * @internal
     */
    async withLockedContainer(args, operation) {
        return CloudSqlite.withWriteLock(args, async () => this.withOpenDb({ ...args, openMode: args.openMode ?? OpenMode.ReadWrite }, operation));
    }
    /** vacuum this database
     * @see https://www.sqlite.org/lang_vacuum.html
     */
    vacuum(args) {
        this[_nativeDb].vacuum(args);
    }
    /** Commit the outermost transaction, writing changes to the file. Then, restart the default transaction. */
    saveChanges() {
        this[_nativeDb].saveChanges();
    }
    /** Abandon (cancel) the outermost transaction, discarding all changes since last save. Then, restart the default transaction. */
    abandonChanges() {
        this[_nativeDb].abandonChanges();
    }
    /**
     * Use a prepared SQL statement, potentially from the statement cache. If the requested statement doesn't exist
     * in the statement cache, a new statement is prepared. After the callback completes, the statement is reset and saved
     * in the statement cache so it can be reused in the future. Use this method for SQL statements that will be
     * reused often and are expensive to prepare. The statement cache holds the most recently used statements, discarding
     * the oldest statements as it fills. For statements you don't intend to reuse, instead use [[withSqliteStatement]].
     * @param sql The SQLite SQL statement to execute
     * @param callback the callback to invoke on the prepared statement
     * @returns the value returned by `callback`.
     */
    withPreparedSqliteStatement(sql, callback) {
        const stmt = this._sqliteStatementCache.findAndRemove(sql) ?? this.prepareSqliteStatement(sql);
        const release = () => this._sqliteStatementCache.addOrDispose(stmt);
        try {
            const val = callback(stmt);
            val instanceof Promise ? val.then(release, release) : release();
            return val;
        }
        catch (err) {
            release();
            throw err;
        }
    }
    /**
     * Prepare and execute a callback on a SQL statement. After the callback completes the statement is disposed.
     * Use this method for SQL statements are either not expected to be reused, or are not expensive to prepare.
     * For statements that will be reused often, instead use [[withPreparedSqliteStatement]].
     * @param sql The SQLite SQL statement to execute
     * @param callback the callback to invoke on the prepared statement
     * @returns the value returned by `callback`.
     */
    withSqliteStatement(sql, callback) {
        const stmt = this.prepareSqliteStatement(sql);
        const release = () => stmt[Symbol.dispose]();
        try {
            const val = callback(stmt);
            val instanceof Promise ? val.then(release, release) : release();
            return val;
        }
        catch (err) {
            release();
            throw err;
        }
    }
    /**
     * Perform an operation on this database within a [savepoint](https://www.sqlite.org/lang_savepoint.html). If the operation completes successfully, the
     * changes remain in the current transaction. If the operation throws an exception, the savepoint is rolled back
     * and all changes to the database from this method are reversed, leaving the transaction exactly as it was before this method.
     */
    withSavePoint(savePointName, operation) {
        if (this.isReadonly)
            SqliteError.throwError("readonly", "database is readonly", this[_nativeDb].getFilePath());
        this.executeSQL(`SAVEPOINT ${savePointName}`);
        try {
            operation();
            this.executeSQL(`RELEASE ${savePointName}`);
        }
        catch (e) {
            this.executeSQL(`ROLLBACK TO ${savePointName}`);
            throw e;
        }
    }
    /** Prepare an SQL statement.
       * @param sql The SQLite SQL statement to prepare
       * @param logErrors Determine if errors are logged or not
       * @internal
       */
    prepareSqliteStatement(sql, logErrors = true) {
        const stmt = new SqliteStatement(sql);
        stmt.prepare(this[_nativeDb], logErrors);
        return stmt;
    }
    /** execute an SQL statement */
    executeSQL(sql) {
        const env_1 = { stack: [], error: void 0, hasError: false };
        try {
            const stmt = __addDisposableResource(env_1, this.prepareSqliteStatement(sql), false);
            return stmt.step();
        }
        catch (e_1) {
            env_1.error = e_1;
            env_1.hasError = true;
        }
        finally {
            __disposeResources(env_1);
        }
    }
}
/**
 * Abstract base class for a SQLite database that has [[SQLiteDb.RequiredVersionRanges]] stored in it.
 * This class provides version checking when the database is opened, to guarantee that a valid version of software is
 * always used for access.
 *
 * Notes:
 *  - This class may be used either to access a local file, or one stored in a cloud container.
 *  - Subclasses must provide a `myVersion` member indicating the version of its software, and implement the `createDDL` member to create its
 * tables.
 * @beta
 */
export class VersionedSqliteDb extends SQLiteDb {
    static _versionProps = { namespace: "SQLiteDb", name: "versions" };
    /**
     * Change the "versions required to open this database" property stored in this database. After this call,
     * versions of software that don't meet the supplied ranges will fail.
     * @param versions the new versions required for reading and writing this database.
     * @note the database must be opened for write access.
     */
    setRequiredVersions(versions) {
        // NOTE: It might look tempting to just stringify the supplied `versions` object, but we only include required members - there may be others.
        this[_nativeDb].saveFileProperty(VersionedSqliteDb._versionProps, JSON.stringify({ readVersion: versions.readVersion, writeVersion: versions.writeVersion }));
    }
    /** Get the required version ranges necessary to open this VersionedSqliteDb. */
    getRequiredVersions() {
        const checkIsString = (value) => {
            if (typeof value !== "string")
                SqliteError.throwError("invalid-versions-property", `CloudDb has invalid "versions" property`, this[_nativeDb].getFilePath());
            return value;
        };
        const versionJson = checkIsString(this[_nativeDb].queryFileProperty(VersionedSqliteDb._versionProps, true));
        const versionRanges = JSON.parse(versionJson);
        checkIsString(versionRanges.readVersion);
        checkIsString(versionRanges.writeVersion);
        return versionRanges;
    }
    /**
     * Create a new database file for the subclass of VersionedSqliteDb.
     * @note The required versions are saved as [[myVersion]] or newer for both read and write.
     */
    static createNewDb(fileName, setupArgs) {
        const db = new this(); // "as any" necessary because VersionedSqliteDb is abstract
        IModelJsFs.recursiveMkDirSync(dirname(fileName));
        if (fs.existsSync(fileName))
            fs.unlinkSync(fileName);
        db.createDb(fileName);
        db.createDDL(setupArgs);
        const minVer = `^${db.myVersion}`;
        db.setRequiredVersions({ readVersion: minVer, writeVersion: minVer });
        db.closeDb(true);
    }
    /**
     * Verify that this version of the software meets the required version range (as appropriate, read or write) stored in the database.
     * Throws otherwise.
     */
    verifyVersions() {
        const versions = this.getRequiredVersions();
        const isReadonly = this.isReadonly; // so we can tell read/write after the file is closed.
        const range = isReadonly ? versions.readVersion : versions.writeVersion;
        if (semver.satisfies(this.myVersion, range))
            return;
        this.closeDb();
        const tooNew = semver.gtr(this.myVersion, range);
        SqliteError.throwError("incompatible-version", `requires ${tooNew ? "older" : "newer"} version of ${this.constructor.name} for ${isReadonly ? "read" : "write"}`, this[_nativeDb].getFilePath());
    }
    /**
     * Open this database and verify that this version of the software meets the required version range (as appropriate, read or write) stored in the database.
     * Throws otherwise.
     * @see [[SqliteDb.openDb]] for argument types
     */
    openDb(dbName, openMode, container) {
        super.openDb(dbName, openMode, container);
        this.verifyVersions();
    }
    async upgradeSchema(arg) {
        // can't use "this" because it checks for version, which we don't want here
        return (arg.lockContainer) ?
            super.withLockedContainer({ dbName: arg.dbName, ...arg.lockContainer }, async () => arg.upgradeFn) :
            super.withOpenDb({ ...arg, openMode: OpenMode.ReadWrite }, arg.upgradeFn);
    }
}
/** @public */
(function (SQLiteDb) {
    /** Default transaction mode for SQLiteDbs.
     * @see https://www.sqlite.org/lang_transaction.html
     */
    let DefaultTxnMode;
    (function (DefaultTxnMode) {
        /** no default transaction is started. You must use BEGIN/COMMIT or SQLite will use implicit transactions */
        DefaultTxnMode[DefaultTxnMode["None"] = 0] = "None";
        /** A deferred transaction is started when the file is first opened. This is the default. */
        DefaultTxnMode[DefaultTxnMode["Deferred"] = 1] = "Deferred";
        /** An immediate transaction is started when the file is first opened. */
        DefaultTxnMode[DefaultTxnMode["Immediate"] = 2] = "Immediate";
        /** An exclusive transaction is started when the file is first opened. */
        DefaultTxnMode[DefaultTxnMode["Exclusive"] = 3] = "Exclusive";
    })(DefaultTxnMode = SQLiteDb.DefaultTxnMode || (SQLiteDb.DefaultTxnMode = {}));
})(SQLiteDb || (SQLiteDb = {}));
//# sourceMappingURL=SQLiteDb.js.map