import { LogContext } from '@rocicorp/logger';
import { assert } from "../../../../shared/src/asserts.js";
import { stringify } from "../../../../shared/src/bigint-json.js";
import { must } from "../../../../shared/src/must.js";
import * as v from "../../../../shared/src/valita.js";
import { Database } from "../../../../zqlite/src/db.js";
import { fromSQLiteTypes } from "../../../../zqlite/src/table-source.js";
import { StatementRunner } from "../../db/statements.js";
import { normalizedKeyOrder, } from "../../types/row-key.js";
import {} from "../../types/schema-versions.js";
import { id } from "../../types/sql.js";
import { RESET_OP, changeLogEntrySchema as schema, SET_OP, TRUNCATE_OP, } from "../replicator/schema/change-log.js";
import { getReplicationState, ZERO_VERSION_COLUMN_NAME as ROW_VERSION, } from "../replicator/schema/replication-state.js";
/**
 * A `Snapshotter` manages the progression of database snapshots for a
 * ViewSyncer.
 *
 * The Replicator and ViewSyncers operate on the same SQLite file, with the
 * Replicator being the sole writer to the database. The IVM logic in
 * ViewSyncers, however, rely on incrementally applying changes to the DB to
 * update the state of its pipelines.
 *
 * To avoid coupling the progress of the Replicator and all IVM pipelines on
 * each other, ViewSyncers operate on ephemeral forks of the database by holding
 * [concurrent](https://sqlite.org/src/doc/begin-concurrent/doc/begin_concurrent.md)
 * snapshots of the database and simulating (but ultimately rolling back)
 * mutations on these snapshots.
 *
 * Example:
 * 1. ViewSyncer takes `snapshot_a` at version `t1` of the database and
 *    hydrates its pipeline(s).
 * 2. Replicator applies a new transaction to the database and notifies
 *    subscribers.
 * 3. ViewSyncer takes `snapshot_b` at `t2`, and queries the `ChangeLog` at
 *    that snapshot for changes since `t1`.
 * 4. ViewSyncer applies those changes to `snapshot_a` for IVM, but does not
 *    commit them. (Recall that the Replicator is the sole writer to the db, so
 *    the ViewSyncer never commits any writes.)
 * 5. Replicator applies the next transaction and advances the database to `t3`.
 * 6. ViewSyncer rolls back `snapshot_a` and opens `snapshot_c` at `t3`, using
 *    `snapshot_b` to simulate changes from `t2` to `t3`.
 *
 * ```
 * Replicator:  t1 --------------> t2 --------------> t3 --------------->
 * ViewSyncer:       [snapshot_a] ----> [snapshot_b] ----> [snapshot_c]
 * ```
 *
 * Note that the Replicator (and ViewSyncers) do not wait on the progress of
 * other ViewSyncers. If a ViewSyncer is busy hydrating at `t1`, the Replicator
 * and other ViewSyncers can progress through `t2`, `t3`, etc. independently,
 * as the busy ViewSyncer simply takes its own snapshot when it is ready.
 *
 * ```
 * Replicator:   t1 --------------> t2 --------------> t3 --------------->
 * ViewSyncer1:       [snapshot_a] ----> [snapshot_b] ----> [snapshot_c]
 * ViewSyncer2:       [.......... snapshot_a ..........] ----> [snapshot_b]
 * ```
 *
 * To minimize Database connections (and statement preparation, etc.), the
 * Snapshotter reuses the connection from the previous (rolled back)
 * snapshot when opening the new one.
 *
 * ```
 * Replicator:  t1 --------------> t2 --------------> t3 --------------->
 * ViewSyncer:       [snapshot_a] ----> [snapshot_b] ----> [snapshot_c]
 *                     (conn_1)           (conn_2)           (conn_1)
 * ```
 *
 * In this manner, each ViewSyncer uses two connections that continually
 * "leapfrog" each other to replay the timeline of changes in isolation from
 * the Replicator and other ViewSyncers.
 */
export class Snapshotter {
    #lc;
    #dbFile;
    #appID;
    #curr;
    #prev;
    constructor(lc, dbFile, { appID }) {
        this.#lc = lc;
        this.#dbFile = dbFile;
        this.#appID = appID;
    }
    /**
     * Initializes the snapshot to the current head of the database. This must be
     * only be called once. The state of whether a Snapshotter has been initialized
     * can be determined by calling {@link initialized()}.
     */
    init() {
        assert(this.#curr === undefined, 'Already initialized');
        this.#curr = Snapshot.create(this.#lc, this.#dbFile, this.#appID);
        this.#lc.debug?.(`Initial snapshot at version ${this.#curr.version}`);
        return this;
    }
    initialized() {
        return this.#curr !== undefined;
    }
    /** Returns the current snapshot. Asserts if {@link initialized()} is false. */
    current() {
        assert(this.#curr !== undefined, 'Snapshotter has not been initialized');
        return this.#curr;
    }
    /**
     * Advances to the head of the Database, returning a diff between the
     * previously current Snapshot and a new Snapshot at head. This is called
     * in response to a notification from a Replicator subscription. Subsequent
     * calls to {@link current()} return the new Snapshot. Note that the Snapshotter
     * must be initialized before advancing.
     *
     * The returned {@link SnapshotDiff} contains snapshots at the endpoints
     * of the database timeline. Iterating over the diff generates a sequence
     * of {@link Change}s between the two snapshots.
     *
     * Note that this sequence is not chronological; rather, the sequence is
     * ordered by `<table, row-key>`, such that a row can appear at most once
     * in the common case, or twice if its table is `TRUNCATE`'d and a new value
     * is subsequently `INSERT`'ed. This results in dropping most intermediate
     * changes to a row and bounds the amount of work needed to catch up;
     * however, as a consequence, a consistent database state is only guaranteed
     * when the sequence has been fully consumed.
     *
     * Note that Change generation relies on the state of the underlying
     * database connections, and because the connection for the previous snapshot
     * is reused to produce the next snapshot, the diff object is only valid
     * until the next call to `advance()`.
     *
     * It is okay for the caller to apply `Change`s to the `prev` snapshot
     * during the iteration (e.g. this is necessary for IVM); the remainder
     * of the iteration is not affected because a given row can appear at most
     * once in the sequence (with the exception being TRUNCATE, after which the
     * deleted rows can be re-inserted, but this will also behave correctly if
     * the changes are applied).
     *
     * Once the changes have been applied, however, a _subsequent_ iteration
     * will not produce the correct results. In order to perform multiple
     * change-applying iterations, the caller must (1) create a save point
     * on `prev` before each iteration, and (2) rollback to the save point after
     * the iteration.
     */
    advance(tables) {
        const { prev, curr } = this.advanceWithoutDiff();
        return new Diff(this.#appID, tables, prev, curr);
    }
    advanceWithoutDiff() {
        assert(this.#curr !== undefined, 'Snapshotter has not been initialized');
        const next = this.#prev
            ? this.#prev.resetToHead()
            : Snapshot.create(this.#lc, this.#curr.db.db.name, this.#appID);
        this.#prev = this.#curr;
        this.#curr = next;
        return { prev: this.#prev, curr: this.#curr };
    }
    /**
     * Call this to close the database connections when the Snapshotter is
     * no longer needed.
     */
    destroy() {
        this.#curr?.db.db.close();
        this.#prev?.db.db.close();
        this.#lc.debug?.('closed database connections');
    }
}
/**
 * Thrown during an iteration of a {@link SnapshotDiff} when a schema
 * change or truncate is encountered, which result in aborting the
 * advancement and resetting / rehydrating the pipelines.
 */
export class ResetPipelinesSignal extends Error {
    name = 'ResetPipelinesSignal';
    constructor(msg) {
        super(msg);
    }
}
function getSchemaVersions(db, appID) {
    return db.get(`SELECT minSupportedVersion, maxSupportedVersion FROM "${appID}.schemaVersions"`);
}
class Snapshot {
    static create(lc, dbFile, appID) {
        const conn = new Database(lc, dbFile);
        conn.pragma('synchronous = OFF'); // Applied changes are ephemeral; COMMIT is never called.
        const [{ journal_mode: mode }] = conn.pragma('journal_mode');
        // The Snapshotter operates on the replica file with BEGIN CONCURRENT,
        // which must be used in concert with the replicator using BEGIN CONCURRENT
        // on a db in the wal2 journal_mode.
        assert(mode === 'wal2', `replica db must be in wal2 mode (current: ${mode})`);
        const db = new StatementRunner(conn);
        return new Snapshot(db, appID);
    }
    db;
    #appID;
    version;
    schemaVersions;
    constructor(db, appID) {
        db.beginConcurrent();
        // Note: The subsequent read is necessary to acquire the read lock
        // (which results in the logical creation of the snapshot). Calling
        // `BEGIN CONCURRENT` alone does not result in acquiring the read lock.
        const { stateVersion } = getReplicationState(db);
        this.db = db;
        this.#appID = appID;
        this.version = stateVersion;
        this.schemaVersions = getSchemaVersions(db, appID);
    }
    numChangesSince(prevVersion) {
        const { count } = this.db.get('SELECT COUNT(*) AS count FROM "_zero.changeLog" WHERE stateVersion > ?', prevVersion);
        return count;
    }
    changesSince(prevVersion) {
        const cached = this.db.statementCache.get('SELECT * FROM "_zero.changeLog" WHERE stateVersion > ?');
        return {
            changes: cached.statement.iterate(prevVersion),
            cleanup: () => this.db.statementCache.return(cached),
        };
    }
    getRow(table, rowKey) {
        const key = normalizedKeyOrder(rowKey);
        const conds = Object.keys(key).map(c => `${id(c)}=?`);
        const cols = Object.keys(table.columns);
        const cached = this.db.statementCache.get(`SELECT ${cols.map(c => id(c)).join(',')} FROM ${id(table.name)} WHERE ${conds.join(' AND ')}`);
        cached.statement.safeIntegers(true);
        try {
            // oxlint-disable-next-line @typescript-eslint/no-explicit-any
            return cached.statement.get(Object.values(key));
        }
        finally {
            this.db.statementCache.return(cached);
        }
    }
    getRows(table) {
        const cols = Object.keys(table.columns);
        const cached = this.db.statementCache.get(`SELECT ${cols.map(c => id(c)).join(',')} FROM ${id(table.name)}`);
        cached.statement.safeIntegers(true);
        return {
            rows: cached.statement.iterate(),
            cleanup: () => this.db.statementCache.return(cached),
        };
    }
    resetToHead() {
        this.db.rollback();
        return new Snapshot(this.db, this.#appID);
    }
}
class Diff {
    #permissionsTable;
    tables;
    prev;
    curr;
    changes;
    constructor(appID, tables, prev, curr) {
        this.#permissionsTable = `${appID}.permissions`;
        this.tables = tables;
        this.prev = prev;
        this.curr = curr;
        this.changes = curr.numChangesSince(prev.version);
    }
    [Symbol.iterator]() {
        const { changes, cleanup: done } = this.curr.changesSince(this.prev.version);
        const cleanup = () => {
            try {
                // Allow open iterators to clean up their state.
                changes.return?.(undefined);
            }
            finally {
                done();
            }
        };
        return {
            next: () => {
                try {
                    for (;;) {
                        const { value, done } = changes.next();
                        if (done) {
                            cleanup();
                            return { value, done: true };
                        }
                        const { table, rowKey, op, stateVersion } = v.parse(value, schema);
                        if (op === RESET_OP) {
                            // The current map of `TableSpec`s may not have the correct or complete information.
                            throw new ResetPipelinesSignal(`schema for table ${table} has changed`);
                        }
                        if (op === TRUNCATE_OP) {
                            // Truncates are also processed by rehydrating pipelines at current.
                            throw new ResetPipelinesSignal(`table ${table} has been truncated`);
                        }
                        const { tableSpec, zqlSpec } = must(this.tables.get(table));
                        assert(rowKey !== null);
                        let prevValue = this.prev.getRow(tableSpec, rowKey) ?? null;
                        let nextValue = op === SET_OP ? this.curr.getRow(tableSpec, rowKey) : null;
                        if (nextValue === undefined) {
                            throw new Error(`Missing value for ${table} ${stringify(rowKey)}`);
                        }
                        // Sanity check detects if the diff is being accessed after the Snapshots have advanced.
                        this.checkThatDiffIsValid(stateVersion, op, prevValue, nextValue);
                        if (prevValue === null && nextValue === null) {
                            // Filter out no-op changes (e.g. a delete of a row that does not exist in prev).
                            // TODO: Consider doing this for deep-equal values.
                            continue;
                        }
                        if (table === this.#permissionsTable &&
                            prevValue.permissions !== nextValue.permissions) {
                            throw new ResetPipelinesSignal(`Permissions have changed ${prevValue.hash} => ${nextValue.hash}`);
                        }
                        // Modify the values in place when converting to ZQL rows
                        // This is safe since we're the first node in the iterator chain.
                        if (prevValue) {
                            prevValue = fromSQLiteTypes(zqlSpec, prevValue, table);
                        }
                        if (nextValue) {
                            nextValue = fromSQLiteTypes(zqlSpec, nextValue, table);
                        }
                        return {
                            value: { table, prevValue, nextValue, rowKey },
                        };
                    }
                }
                catch (e) {
                    // This control flow path is not covered by the return() method (i.e. `break`).
                    cleanup();
                    throw e;
                }
            },
            return: (value) => {
                cleanup();
                return { value, done: true };
            },
        };
    }
    checkThatDiffIsValid(stateVersion, op, prevValue, nextValue) {
        // Sanity checks to detect that the diff is not being accessed after
        // the Snapshots have advanced.
        if (stateVersion > this.curr.version) {
            throw new InvalidDiffError(`Diff is no longer valid. curr db has advanced past ${this.curr.version}`);
        }
        if (prevValue !== null &&
            (prevValue[ROW_VERSION] ?? '~') > this.prev.version) {
            throw new InvalidDiffError(`Diff is no longer valid. prev db has advanced past ${this.prev.version}.`);
        }
        if (op === SET_OP && nextValue[ROW_VERSION] !== stateVersion) {
            throw new InvalidDiffError('Diff is no longer valid. curr db has advanced.');
        }
    }
}
export class InvalidDiffError extends Error {
    constructor(msg) {
        super(msg);
    }
}
//# sourceMappingURL=snapshotter.js.map