import { EventIterator } from 'event-iterator';
import Logger from 'js-logger';
import { isBatchedUpdateNotification } from '../db/DBAdapter.js';
import { SyncStatus } from '../db/crud/SyncStatus.js';
import { UploadQueueStats } from '../db/crud/UploadQueueStatus.js';
import { BaseObserver } from '../utils/BaseObserver.js';
import { ControlledExecutor } from '../utils/ControlledExecutor.js';
import { symbolAsyncIterator, throttleTrailing } from '../utils/async.js';
import { ConnectionManager } from './ConnectionManager.js';
import { CustomQuery } from './CustomQuery.js';
import { isDBAdapter, isSQLOpenFactory, isSQLOpenOptions } from './SQLOpenFactory.js';
import { PSInternalTable } from './sync/bucket/BucketStorageAdapter.js';
import { CrudBatch } from './sync/bucket/CrudBatch.js';
import { CrudEntry } from './sync/bucket/CrudEntry.js';
import { CrudTransaction } from './sync/bucket/CrudTransaction.js';
import { DEFAULT_CRUD_UPLOAD_THROTTLE_MS, DEFAULT_RETRY_DELAY_MS } from './sync/stream/AbstractStreamingSyncImplementation.js';
import { coreStatusToJs } from './sync/stream/core-instruction.js';
import { MEMORY_TRIGGER_CLAIM_MANAGER } from './triggers/MemoryTriggerClaimManager.js';
import { TriggerManagerImpl } from './triggers/TriggerManagerImpl.js';
import { DEFAULT_WATCH_THROTTLE_MS } from './watched/WatchedQuery.js';
import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js';
import { Mutex } from '../utils/mutex.js';
const POWERSYNC_TABLE_MATCH = /(^ps_data__|^ps_data_local__)/;
const DEFAULT_DISCONNECT_CLEAR_OPTIONS = {
    clearLocal: true
};
export const DEFAULT_POWERSYNC_CLOSE_OPTIONS = {
    disconnect: true
};
export const DEFAULT_POWERSYNC_DB_OPTIONS = {
    retryDelayMs: 5000,
    crudUploadThrottleMs: DEFAULT_CRUD_UPLOAD_THROTTLE_MS
};
export const DEFAULT_CRUD_BATCH_LIMIT = 100;
/**
 * Requesting nested or recursive locks can block the application in some circumstances.
 * This default lock timeout will act as a failsafe to throw an error if a lock cannot
 * be obtained.
 */
export const DEFAULT_LOCK_TIMEOUT_MS = 120_000; // 2 mins
/**
 * Tests if the input is a {@link PowerSyncDatabaseOptionsWithSettings}
 * @internal
 */
export const isPowerSyncDatabaseOptionsWithSettings = (test) => {
    return typeof test == 'object' && isSQLOpenOptions(test.database);
};
export class AbstractPowerSyncDatabase extends BaseObserver {
    options;
    /**
     * Returns true if the connection is closed.
     */
    closed;
    ready;
    /**
     * Current connection status.
     */
    currentStatus;
    sdkVersion;
    bucketStorageAdapter;
    _isReadyPromise;
    connectionManager;
    subscriptions;
    get syncStreamImplementation() {
        return this.connectionManager.syncStreamImplementation;
    }
    /**
     * The connector used to connect to the PowerSync service.
     *
     * @returns The connector used to connect to the PowerSync service or null if `connect()` has not been called.
     */
    get connector() {
        return this.connectionManager.connector;
    }
    /**
     * The resolved connection options used to connect to the PowerSync service.
     *
     * @returns The resolved connection options used to connect to the PowerSync service or null if `connect()` has not been called.
     */
    get connectionOptions() {
        return this.connectionManager.connectionOptions;
    }
    _schema;
    _database;
    runExclusiveMutex;
    /**
     * @experimental
     * Allows creating SQLite triggers which can be used to track various operations on SQLite tables.
     */
    triggers;
    triggersImpl;
    logger;
    constructor(options) {
        super();
        this.options = options;
        const { database, schema } = options;
        if (typeof schema?.toJSON != 'function') {
            throw new Error('The `schema` option should be provided and should be an instance of `Schema`.');
        }
        if (isDBAdapter(database)) {
            this._database = database;
        }
        else if (isSQLOpenFactory(database)) {
            this._database = database.openDB();
        }
        else if (isPowerSyncDatabaseOptionsWithSettings(options)) {
            this._database = this.openDBAdapter(options);
        }
        else {
            throw new Error('The provided `database` option is invalid.');
        }
        this.logger = options.logger ?? Logger.get(`PowerSyncDatabase[${this._database.name}]`);
        this.bucketStorageAdapter = this.generateBucketStorageAdapter();
        this.closed = false;
        this.currentStatus = new SyncStatus({});
        this.options = { ...DEFAULT_POWERSYNC_DB_OPTIONS, ...options };
        this._schema = schema;
        this.ready = false;
        this.sdkVersion = '';
        this.runExclusiveMutex = new Mutex();
        // Start async init
        this.subscriptions = {
            firstStatusMatching: (predicate, abort) => this.waitForStatus(predicate, abort),
            resolveOfflineSyncStatus: () => this.resolveOfflineSyncStatus(),
            rustSubscriptionsCommand: async (payload) => {
                await this.writeTransaction((tx) => {
                    return tx.execute('select powersync_control(?,?)', ['subscriptions', JSON.stringify(payload)]);
                });
            }
        };
        this.connectionManager = new ConnectionManager({
            createSyncImplementation: async (connector, options) => {
                await this.waitForReady();
                return this.runExclusive(async () => {
                    const sync = this.generateSyncStreamImplementation(connector, this.resolvedConnectionOptions(options));
                    const onDispose = sync.registerListener({
                        statusChanged: (status) => {
                            this.currentStatus = new SyncStatus({
                                ...status.toJSON(),
                                hasSynced: this.currentStatus?.hasSynced || !!status.lastSyncedAt
                            });
                            this.iterateListeners((cb) => cb.statusChanged?.(this.currentStatus));
                        }
                    });
                    await sync.waitForReady();
                    return {
                        sync,
                        onDispose
                    };
                });
            },
            logger: this.logger
        });
        this._isReadyPromise = this.initialize();
        this.triggers = this.triggersImpl = new TriggerManagerImpl({
            db: this,
            schema: this.schema,
            ...this.generateTriggerManagerConfig()
        });
    }
    /**
     * Schema used for the local database.
     */
    get schema() {
        return this._schema;
    }
    /**
     * The underlying database.
     *
     * For the most part, behavior is the same whether querying on the underlying database, or on {@link AbstractPowerSyncDatabase}.
     */
    get database() {
        return this._database;
    }
    /**
     * Whether a connection to the PowerSync service is currently open.
     */
    get connected() {
        return this.currentStatus?.connected || false;
    }
    get connecting() {
        return this.currentStatus?.connecting || false;
    }
    /**
     * Generates a base configuration for {@link TriggerManagerImpl}.
     * Implementations should override this if necessary.
     */
    generateTriggerManagerConfig() {
        return {
            claimManager: MEMORY_TRIGGER_CLAIM_MANAGER
        };
    }
    /**
     * @returns A promise which will resolve once initialization is completed.
     */
    async waitForReady() {
        if (this.ready) {
            return;
        }
        await this._isReadyPromise;
    }
    /**
     * Wait for the first sync operation to complete.
     *
     * @param request Either an abort signal (after which the promise will complete regardless of
     * whether a full sync was completed) or an object providing an abort signal and a priority target.
     * When a priority target is set, the promise may complete when all buckets with the given (or higher)
     * priorities have been synchronized. This can be earlier than a complete sync.
     * @returns A promise which will resolve once the first full sync has completed.
     */
    async waitForFirstSync(request) {
        const signal = request instanceof AbortSignal ? request : request?.signal;
        const priority = request && 'priority' in request ? request.priority : undefined;
        const statusMatches = priority === undefined
            ? (status) => status.hasSynced
            : (status) => status.statusForPriority(priority).hasSynced;
        return this.waitForStatus(statusMatches, signal);
    }
    /**
     * Waits for the first sync status for which the `status` callback returns a truthy value.
     */
    async waitForStatus(predicate, signal) {
        if (predicate(this.currentStatus)) {
            return;
        }
        return new Promise((resolve) => {
            const dispose = this.registerListener({
                statusChanged: (status) => {
                    if (predicate(status)) {
                        abort();
                    }
                }
            });
            function abort() {
                dispose();
                resolve();
            }
            if (signal?.aborted) {
                abort();
            }
            else {
                signal?.addEventListener('abort', abort);
            }
        });
    }
    /**
     * Entry point for executing initialization logic.
     * This is to be automatically executed in the constructor.
     */
    async initialize() {
        await this._initialize();
        await this.bucketStorageAdapter.init();
        await this.loadVersion();
        await this.updateSchema(this.options.schema);
        await this.resolveOfflineSyncStatus();
        await this.database.execute('PRAGMA RECURSIVE_TRIGGERS=TRUE');
        await this.triggersImpl.cleanupResources();
        this.ready = true;
        this.iterateListeners((cb) => cb.initialized?.());
    }
    async loadVersion() {
        try {
            const { version } = await this.database.get('SELECT powersync_rs_version() as version');
            this.sdkVersion = version;
        }
        catch (e) {
            throw new Error(`The powersync extension is not loaded correctly. Details: ${e.message}`);
        }
        let versionInts;
        try {
            versionInts = this.sdkVersion.split(/[.\/]/)
                .slice(0, 3)
                .map((n) => parseInt(n));
        }
        catch (e) {
            throw new Error(`Unsupported powersync extension version. Need >=0.4.10 <1.0.0, got: ${this.sdkVersion}. Details: ${e.message}`);
        }
        // Validate >=0.4.10 <1.0.0
        if (versionInts[0] != 0 || versionInts[1] < 4 || (versionInts[1] == 4 && versionInts[2] < 10)) {
            throw new Error(`Unsupported powersync extension version. Need >=0.4.10 <1.0.0, got: ${this.sdkVersion}`);
        }
    }
    async resolveOfflineSyncStatus() {
        const result = await this.database.get('SELECT powersync_offline_sync_status() as r');
        const parsed = JSON.parse(result.r);
        const updatedStatus = new SyncStatus({
            ...this.currentStatus.toJSON(),
            ...coreStatusToJs(parsed)
        });
        if (!updatedStatus.isEqual(this.currentStatus)) {
            this.currentStatus = updatedStatus;
            this.iterateListeners((l) => l.statusChanged?.(this.currentStatus));
        }
    }
    /**
     * Replace the schema with a new version. This is for advanced use cases - typically the schema should just be specified once in the constructor.
     *
     * Cannot be used while connected - this should only be called before {@link AbstractPowerSyncDatabase.connect}.
     */
    async updateSchema(schema) {
        if (this.syncStreamImplementation) {
            throw new Error('Cannot update schema while connected');
        }
        /**
         * TODO
         * Validations only show a warning for now.
         * The next major release should throw an exception.
         */
        try {
            schema.validate();
        }
        catch (ex) {
            this.logger.warn('Schema validation failed. Unexpected behaviour could occur', ex);
        }
        this._schema = schema;
        await this.database.execute('SELECT powersync_replace_schema(?)', [JSON.stringify(this.schema.toJSON())]);
        await this.database.refreshSchema();
        this.iterateListeners(async (cb) => cb.schemaChanged?.(schema));
    }
    /**
     * Wait for initialization to complete.
     * While initializing is automatic, this helps to catch and report initialization errors.
     */
    async init() {
        return this.waitForReady();
    }
    // Use the options passed in during connect, or fallback to the options set during database creation or fallback to the default options
    resolvedConnectionOptions(options) {
        return {
            ...options,
            retryDelayMs: options?.retryDelayMs ?? this.options.retryDelayMs ?? this.options.retryDelay ?? DEFAULT_RETRY_DELAY_MS,
            crudUploadThrottleMs: options?.crudUploadThrottleMs ?? this.options.crudUploadThrottleMs ?? DEFAULT_CRUD_UPLOAD_THROTTLE_MS
        };
    }
    /**
     * @deprecated Use {@link AbstractPowerSyncDatabase#close} instead.
     * Clears all listeners registered by {@link AbstractPowerSyncDatabase#registerListener}.
     */
    dispose() {
        return super.dispose();
    }
    /**
     * Locking mechanism for exclusively running critical portions of connect/disconnect operations.
     * Locking here is mostly only important on web for multiple tab scenarios.
     */
    runExclusive(callback) {
        return this.runExclusiveMutex.runExclusive(callback);
    }
    /**
     * Connects to stream of events from the PowerSync instance.
     */
    async connect(connector, options) {
        const resolvedOptions = options ?? {};
        resolvedOptions.serializedSchema = this.schema.toJSON();
        return this.connectionManager.connect(connector, resolvedOptions);
    }
    /**
     * Close the sync connection.
     *
     * Use {@link connect} to connect again.
     */
    async disconnect() {
        return this.connectionManager.disconnect();
    }
    /**
     *  Disconnect and clear the database.
     *  Use this when logging out.
     *  The database can still be queried after this is called, but the tables
     *  would be empty.
     *
     * To preserve data in local-only tables, set clearLocal to false.
     */
    async disconnectAndClear(options = DEFAULT_DISCONNECT_CLEAR_OPTIONS) {
        await this.disconnect();
        await this.waitForReady();
        const { clearLocal } = options;
        await this.database.writeTransaction(async (tx) => {
            await tx.execute('SELECT powersync_clear(?)', [clearLocal ? 1 : 0]);
        });
        // The data has been deleted - reset the sync status
        this.currentStatus = new SyncStatus({});
        this.iterateListeners((l) => l.statusChanged?.(this.currentStatus));
    }
    /**
     * Create a sync stream to query its status or to subscribe to it.
     *
     * @param name The name of the stream to subscribe to.
     * @param params Optional parameters for the stream subscription.
     * @returns A {@link SyncStream} instance that can be subscribed to.
     * @experimental Sync streams are currently in alpha.
     */
    syncStream(name, params) {
        return this.connectionManager.stream(this.subscriptions, name, params ?? null);
    }
    /**
     * Close the database, releasing resources.
     *
     * Also disconnects any active connection.
     *
     * Once close is called, this connection cannot be used again - a new one
     * must be constructed.
     */
    async close(options = DEFAULT_POWERSYNC_CLOSE_OPTIONS) {
        await this.waitForReady();
        if (this.closed) {
            return;
        }
        this.triggersImpl.dispose();
        await this.iterateAsyncListeners(async (cb) => cb.closing?.());
        const { disconnect } = options;
        if (disconnect) {
            await this.disconnect();
        }
        await this.connectionManager.close();
        await this.database.close();
        this.closed = true;
        await this.iterateAsyncListeners(async (cb) => cb.closed?.());
    }
    /**
     * Get upload queue size estimate and count.
     */
    async getUploadQueueStats(includeSize) {
        return this.readTransaction(async (tx) => {
            if (includeSize) {
                const result = await tx.execute(`SELECT SUM(cast(data as blob) + 20) as size, count(*) as count FROM ${PSInternalTable.CRUD}`);
                const row = result.rows.item(0);
                return new UploadQueueStats(row?.count ?? 0, row?.size ?? 0);
            }
            else {
                const result = await tx.execute(`SELECT count(*) as count FROM ${PSInternalTable.CRUD}`);
                const row = result.rows.item(0);
                return new UploadQueueStats(row?.count ?? 0);
            }
        });
    }
    /**
     * Get a batch of CRUD data to upload.
     *
     * Returns null if there is no data to upload.
     *
     * Use this from the {@link PowerSyncBackendConnector.uploadData} callback.
     *
     * Once the data have been successfully uploaded, call {@link CrudBatch.complete} before
     * requesting the next batch.
     *
     * Use {@link limit} to specify the maximum number of updates to return in a single
     * batch.
     *
     * This method does include transaction ids in the result, but does not group
     * data by transaction. One batch may contain data from multiple transactions,
     * and a single transaction may be split over multiple batches.
     *
     * @param limit Maximum number of CRUD entries to include in the batch
     * @returns A batch of CRUD operations to upload, or null if there are none
     */
    async getCrudBatch(limit = DEFAULT_CRUD_BATCH_LIMIT) {
        const result = await this.getAll(`SELECT id, tx_id, data FROM ${PSInternalTable.CRUD} ORDER BY id ASC LIMIT ?`, [limit + 1]);
        const all = result.map((row) => CrudEntry.fromRow(row)) ?? [];
        let haveMore = false;
        if (all.length > limit) {
            all.pop();
            haveMore = true;
        }
        if (all.length == 0) {
            return null;
        }
        const last = all[all.length - 1];
        return new CrudBatch(all, haveMore, async (writeCheckpoint) => this.handleCrudCheckpoint(last.clientId, writeCheckpoint));
    }
    /**
     * Get the next recorded transaction to upload.
     *
     * Returns null if there is no data to upload.
     *
     * Use this from the {@link PowerSyncBackendConnector.uploadData} callback.
     *
     * Once the data have been successfully uploaded, call {@link CrudTransaction.complete} before
     * requesting the next transaction.
     *
     * Unlike {@link getCrudBatch}, this only returns data from a single transaction at a time.
     * All data for the transaction is loaded into memory.
     *
     * @returns A transaction of CRUD operations to upload, or null if there are none
     */
    async getNextCrudTransaction() {
        const iterator = this.getCrudTransactions()[symbolAsyncIterator]();
        return (await iterator.next()).value;
    }
    /**
     * Returns an async iterator of completed transactions with local writes against the database.
     *
     * This is typically used from the {@link PowerSyncBackendConnector.uploadData} callback. Each entry emitted by the
     * returned iterator is a full transaction containing all local writes made while that transaction was active.
     *
     * Unlike {@link getNextCrudTransaction}, which always returns the oldest transaction that hasn't been
     * {@link CrudTransaction.complete}d yet, this iterator can be used to receive multiple transactions. Calling
     * {@link CrudTransaction.complete} will mark that and all prior transactions emitted by the iterator as completed.
     *
     * This can be used to upload multiple transactions in a single batch, e.g with:
     *
     * ```JavaScript
     * let lastTransaction = null;
     * let batch = [];
     *
     * for await (const transaction of database.getCrudTransactions()) {
     *   batch.push(...transaction.crud);
     *   lastTransaction = transaction;
     *
     *   if (batch.length > 10) {
     *     break;
     *    }
     * }
     * ```
     *
     * If there is no local data to upload, the async iterator complete without emitting any items.
     *
     * Note that iterating over async iterables requires a [polyfill](https://github.com/powersync-ja/powersync-js/tree/main/packages/react-native#babel-plugins-watched-queries)
     * for React Native.
     */
    getCrudTransactions() {
        return {
            [symbolAsyncIterator]: () => {
                let lastCrudItemId = -1;
                const sql = `
WITH RECURSIVE crud_entries AS (
  SELECT id, tx_id, data FROM ps_crud WHERE id = (SELECT min(id) FROM ps_crud WHERE id > ?)
  UNION ALL
  SELECT ps_crud.id, ps_crud.tx_id, ps_crud.data FROM ps_crud
    INNER JOIN crud_entries ON crud_entries.id + 1 = rowid
  WHERE crud_entries.tx_id = ps_crud.tx_id
)
SELECT * FROM crud_entries;
    `;
                return {
                    next: async () => {
                        const nextTransaction = await this.database.getAll(sql, [lastCrudItemId]);
                        if (nextTransaction.length == 0) {
                            return { done: true, value: null };
                        }
                        const items = nextTransaction.map((row) => CrudEntry.fromRow(row));
                        const last = items[items.length - 1];
                        const txId = last.transactionId;
                        lastCrudItemId = last.clientId;
                        return {
                            done: false,
                            value: new CrudTransaction(items, async (writeCheckpoint) => this.handleCrudCheckpoint(last.clientId, writeCheckpoint), txId)
                        };
                    }
                };
            }
        };
    }
    /**
     * Get an unique client id for this database.
     *
     * The id is not reset when the database is cleared, only when the database is deleted.
     *
     * @returns A unique identifier for the database instance
     */
    async getClientId() {
        return this.bucketStorageAdapter.getClientId();
    }
    async handleCrudCheckpoint(lastClientId, writeCheckpoint) {
        return this.writeTransaction(async (tx) => {
            await tx.execute(`DELETE FROM ${PSInternalTable.CRUD} WHERE id <= ?`, [lastClientId]);
            if (writeCheckpoint) {
                const check = await tx.execute(`SELECT 1 FROM ${PSInternalTable.CRUD} LIMIT 1`);
                if (!check.rows?.length) {
                    await tx.execute(`UPDATE ${PSInternalTable.BUCKETS} SET target_op = CAST(? as INTEGER) WHERE name='$local'`, [
                        writeCheckpoint
                    ]);
                }
            }
            else {
                await tx.execute(`UPDATE ${PSInternalTable.BUCKETS} SET target_op = CAST(? as INTEGER) WHERE name='$local'`, [
                    this.bucketStorageAdapter.getMaxOpId()
                ]);
            }
        });
    }
    /**
     * Execute a SQL write (INSERT/UPDATE/DELETE) query
     * and optionally return results.
     *
     * When using the default client-side [JSON-based view system](https://docs.powersync.com/architecture/client-architecture#client-side-schema-and-sqlite-database-structure),
     * the returned result's `rowsAffected` may be `0` for successful `UPDATE` and `DELETE` statements.
     * Use a `RETURNING` clause and inspect `result.rows` when you need to confirm which rows changed.
     *
     * @param sql The SQL query to execute
     * @param parameters Optional array of parameters to bind to the query
     * @returns The query result as an object with structured key-value pairs
     */
    async execute(sql, parameters) {
        return this.writeLock((tx) => tx.execute(sql, parameters));
    }
    /**
     * Execute a SQL write (INSERT/UPDATE/DELETE) query directly on the database without any PowerSync processing.
     * This bypasses certain PowerSync abstractions and is useful for accessing the raw database results.
     *
     * @param sql The SQL query to execute
     * @param parameters Optional array of parameters to bind to the query
     * @returns The raw query result from the underlying database as a nested array of raw values, where each row is
     * represented as an array of column values without field names.
     */
    async executeRaw(sql, parameters) {
        await this.waitForReady();
        return this.database.executeRaw(sql, parameters);
    }
    /**
     * Execute a write query (INSERT/UPDATE/DELETE) multiple times with each parameter set
     * and optionally return results.
     * This is faster than executing separately with each parameter set.
     *
     * @param sql The SQL query to execute
     * @param parameters Optional 2D array of parameter sets, where each inner array is a set of parameters for one execution
     * @returns The query result
     */
    async executeBatch(sql, parameters) {
        await this.waitForReady();
        return this.database.executeBatch(sql, parameters);
    }
    /**
     *  Execute a read-only query and return results.
     *
     * @param sql The SQL query to execute
     * @param parameters Optional array of parameters to bind to the query
     * @returns An array of results
     */
    async getAll(sql, parameters) {
        await this.waitForReady();
        return this.database.getAll(sql, parameters);
    }
    /**
     * Execute a read-only query and return the first result, or null if the ResultSet is empty.
     *
     * @param sql The SQL query to execute
     * @param parameters Optional array of parameters to bind to the query
     * @returns The first result if found, or null if no results are returned
     */
    async getOptional(sql, parameters) {
        await this.waitForReady();
        return this.database.getOptional(sql, parameters);
    }
    /**
     * Execute a read-only query and return the first result, error if the ResultSet is empty.
     *
     * @param sql The SQL query to execute
     * @param parameters Optional array of parameters to bind to the query
     * @returns The first result matching the query
     * @throws Error if no rows are returned
     */
    async get(sql, parameters) {
        await this.waitForReady();
        return this.database.get(sql, parameters);
    }
    /**
     * Takes a read lock, without starting a transaction.
     * In most cases, {@link readTransaction} should be used instead.
     */
    async readLock(callback) {
        await this.waitForReady();
        return this.database.readLock(callback);
    }
    /**
     * Takes a global lock, without starting a transaction.
     * In most cases, {@link writeTransaction} should be used instead.
     */
    async writeLock(callback) {
        await this.waitForReady();
        return this.database.writeLock(callback);
    }
    /**
     * Open a read-only transaction.
     * Read transactions can run concurrently to a write transaction.
     * Changes from any write transaction are not visible to read transactions started before it.
     *
     * @param callback Function to execute within the transaction
     * @param lockTimeout Time in milliseconds to wait for a lock before throwing an error
     * @returns The result of the callback
     * @throws Error if the lock cannot be obtained within the timeout period
     */
    async readTransaction(callback, lockTimeout = DEFAULT_LOCK_TIMEOUT_MS) {
        await this.waitForReady();
        return this.database.readTransaction(async (tx) => {
            const res = await callback(tx);
            await tx.rollback();
            return res;
        }, { timeoutMs: lockTimeout });
    }
    /**
     * Open a read-write transaction.
     * This takes a global lock - only one write transaction can execute against the database at a time.
     * Statements within the transaction must be done on the provided {@link Transaction} interface.
     *
     * @param callback Function to execute within the transaction
     * @param lockTimeout Time in milliseconds to wait for a lock before throwing an error
     * @returns The result of the callback
     * @throws Error if the lock cannot be obtained within the timeout period
     */
    async writeTransaction(callback, lockTimeout = DEFAULT_LOCK_TIMEOUT_MS) {
        await this.waitForReady();
        return this.database.writeTransaction(async (tx) => {
            const res = await callback(tx);
            await tx.commit();
            return res;
        }, { timeoutMs: lockTimeout });
    }
    watch(sql, parameters, handlerOrOptions, maybeOptions) {
        if (handlerOrOptions && typeof handlerOrOptions === 'object' && 'onResult' in handlerOrOptions) {
            const handler = handlerOrOptions;
            const options = maybeOptions;
            return this.watchWithCallback(sql, parameters, handler, options);
        }
        const options = handlerOrOptions;
        return this.watchWithAsyncGenerator(sql, parameters, options);
    }
    /**
     * Allows defining a query which can be used to build a {@link WatchedQuery}.
     * The defined query will be executed with {@link AbstractPowerSyncDatabase#getAll}.
     * An optional mapper function can be provided to transform the results.
     *
     * @example
     * ```javascript
     * const watchedTodos = powersync.query({
     *  sql: `SELECT photo_id as id FROM todos WHERE photo_id IS NOT NULL`,
     *  parameters: [],
     *  mapper: (row) => ({
     *    ...row,
     *    created_at: new Date(row.created_at as string)
     *  })
     * })
     * .watch()
     * // OR use .differentialWatch() for fine-grained watches.
     * ```
     */
    query(query) {
        const { sql, parameters = [], mapper } = query;
        const compatibleQuery = {
            compile: () => ({
                sql,
                parameters
            }),
            execute: async ({ sql, parameters }) => {
                const result = await this.getAll(sql, parameters);
                return mapper ? result.map(mapper) : result;
            }
        };
        return this.customQuery(compatibleQuery);
    }
    /**
     * Allows building a {@link WatchedQuery} using an existing {@link WatchCompatibleQuery}.
     * The watched query will use the provided {@link WatchCompatibleQuery.execute} method to query results.
     *
     * @example
     * ```javascript
     *
     * // Potentially a query from an ORM like Drizzle
     * const query = db.select().from(lists);
     *
     * const watchedTodos = powersync.customQuery(query)
     * .watch()
     * // OR use .differentialWatch() for fine-grained watches.
     * ```
     */
    customQuery(query) {
        return new CustomQuery({
            db: this,
            query
        });
    }
    /**
     * Execute a read query every time the source tables are modified.
     * Use {@link SQLWatchOptions.throttleMs} to specify the minimum interval between queries.
     * Source tables are automatically detected using `EXPLAIN QUERY PLAN`.
     *
     * Note that the `onChange` callback member of the handler is required.
     *
     * @param sql The SQL query to execute
     * @param parameters Optional array of parameters to bind to the query
     * @param handler Callbacks for handling results and errors
     * @param options Options for configuring watch behavior
     */
    watchWithCallback(sql, parameters, handler, options) {
        const { onResult, onError = (e) => this.logger.error(e) } = handler ?? {};
        if (!onResult) {
            throw new Error('onResult is required');
        }
        const { comparator } = options ?? {};
        // This API yields a QueryResult type.
        // This is not a standard Array result, which makes it incompatible with the .query API.
        const watchedQuery = new OnChangeQueryProcessor({
            db: this,
            comparator,
            placeholderData: null,
            watchOptions: {
                query: {
                    compile: () => ({
                        sql: sql,
                        parameters: parameters ?? []
                    }),
                    execute: () => this.executeReadOnly(sql, parameters)
                },
                reportFetching: false,
                throttleMs: options?.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS,
                triggerOnTables: options?.tables
            }
        });
        const dispose = watchedQuery.registerListener({
            onData: (data) => {
                if (!data) {
                    // This should not happen. We only use null for the initial data.
                    return;
                }
                onResult(data);
            },
            onError: (error) => {
                onError(error);
            }
        });
        options?.signal?.addEventListener('abort', () => {
            dispose();
            watchedQuery.close();
        });
    }
    /**
     * Execute a read query every time the source tables are modified.
     * Use {@link SQLWatchOptions.throttleMs} to specify the minimum interval between queries.
     * Source tables are automatically detected using `EXPLAIN QUERY PLAN`.
     *
     * @param sql The SQL query to execute
     * @param parameters Optional array of parameters to bind to the query
     * @param options Options for configuring watch behavior
     * @returns An AsyncIterable that yields QueryResults whenever the data changes
     */
    watchWithAsyncGenerator(sql, parameters, options) {
        return new EventIterator((eventOptions) => {
            const handler = {
                onResult: (result) => {
                    eventOptions.push(result);
                },
                onError: (error) => {
                    eventOptions.fail(error);
                }
            };
            this.watchWithCallback(sql, parameters, handler, options);
            options?.signal?.addEventListener('abort', () => {
                eventOptions.stop();
            });
        });
    }
    /**
     * Resolves the list of tables that are used in a SQL query.
     * If tables are specified in the options, those are used directly.
     * Otherwise, analyzes the query using EXPLAIN to determine which tables are accessed.
     *
     * @param sql The SQL query to analyze
     * @param parameters Optional parameters for the SQL query
     * @param options Optional watch options that may contain explicit table list
     * @returns Array of table names that the query depends on
     */
    async resolveTables(sql, parameters, options) {
        const resolvedTables = options?.tables ? [...options.tables] : [];
        if (!options?.tables) {
            const explained = await this.getAll(`EXPLAIN ${sql}`, parameters);
            const rootPages = explained
                .filter((row) => row.opcode == 'OpenRead' && row.p3 == 0 && typeof row.p2 == 'number')
                .map((row) => row.p2);
            const tables = await this.getAll(`SELECT DISTINCT tbl_name FROM sqlite_master WHERE rootpage IN (SELECT json_each.value FROM json_each(?))`, [JSON.stringify(rootPages)]);
            for (const table of tables) {
                resolvedTables.push(table.tbl_name.replace(POWERSYNC_TABLE_MATCH, ''));
            }
        }
        return resolvedTables;
    }
    onChange(handlerOrOptions, maybeOptions) {
        if (handlerOrOptions && typeof handlerOrOptions === 'object' && 'onChange' in handlerOrOptions) {
            const handler = handlerOrOptions;
            const options = maybeOptions;
            return this.onChangeWithCallback(handler, options);
        }
        const options = handlerOrOptions;
        return this.onChangeWithAsyncGenerator(options);
    }
    /**
     * Invoke the provided callback on any changes to any of the specified tables.
     *
     * This is preferred over {@link watchWithCallback} when multiple queries need to be performed
     * together when data is changed.
     *
     * Note that the `onChange` callback member of the handler is required.
     *
     * @param handler Callbacks for handling change events and errors
     * @param options Options for configuring watch behavior
     * @returns A dispose function to stop watching for changes
     */
    onChangeWithCallback(handler, options) {
        const { onChange, onError = (e) => this.logger.error(e) } = handler ?? {};
        if (!onChange) {
            throw new Error('onChange is required');
        }
        const resolvedOptions = options ?? {};
        const watchedTables = new Set((resolvedOptions?.tables ?? []).flatMap((table) => [table, `ps_data__${table}`, `ps_data_local__${table}`]));
        const changedTables = new Set();
        const throttleMs = resolvedOptions.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS;
        const executor = new ControlledExecutor(async (e) => {
            await onChange(e);
        });
        const flushTableUpdates = throttleTrailing(() => this.handleTableChanges(changedTables, watchedTables, (intersection) => {
            if (resolvedOptions?.signal?.aborted)
                return;
            executor.schedule({ changedTables: intersection });
        }), throttleMs);
        if (options?.triggerImmediate) {
            executor.schedule({ changedTables: [] });
        }
        const dispose = this.database.registerListener({
            tablesUpdated: async (update) => {
                try {
                    this.processTableUpdates(update, changedTables);
                    flushTableUpdates();
                }
                catch (error) {
                    onError?.(error);
                }
            }
        });
        resolvedOptions.signal?.addEventListener('abort', () => {
            executor.dispose();
            dispose();
        });
        return () => dispose();
    }
    /**
     * Create a Stream of changes to any of the specified tables.
     *
     * This is preferred over {@link watchWithAsyncGenerator} when multiple queries need to be performed
     * together when data is changed.
     *
     * Note: do not declare this as `async *onChange` as it will not work in React Native.
     *
     * @param options Options for configuring watch behavior
     * @returns An AsyncIterable that yields change events whenever the specified tables change
     */
    onChangeWithAsyncGenerator(options) {
        const resolvedOptions = options ?? {};
        return new EventIterator((eventOptions) => {
            const dispose = this.onChangeWithCallback({
                onChange: (event) => {
                    eventOptions.push(event);
                },
                onError: (error) => {
                    eventOptions.fail(error);
                }
            }, options);
            resolvedOptions.signal?.addEventListener('abort', () => {
                eventOptions.stop();
                // Maybe fail?
            });
            return () => dispose();
        });
    }
    handleTableChanges(changedTables, watchedTables, onDetectedChanges) {
        if (changedTables.size > 0) {
            const intersection = Array.from(changedTables.values()).filter((change) => watchedTables.has(change));
            if (intersection.length) {
                onDetectedChanges(intersection);
            }
        }
        changedTables.clear();
    }
    processTableUpdates(updateNotification, changedTables) {
        const tables = isBatchedUpdateNotification(updateNotification)
            ? updateNotification.tables
            : [updateNotification.table];
        for (const table of tables) {
            changedTables.add(table);
        }
    }
    /**
     * @ignore
     */
    async executeReadOnly(sql, params) {
        await this.waitForReady();
        return this.database.readLock((tx) => tx.execute(sql, params));
    }
}
//# sourceMappingURL=AbstractPowerSyncDatabase.js.map