import { assert } from "../../../../shared/src/asserts.js";
import { stringify } from "../../../../shared/src/bigint-json.js";
import { must } from "../../../../shared/src/must.js";
import { parse } 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 { id } from "../../types/sql.js";
import { changeLogEntrySchema, RESET_OP, TRUNCATE_OP, SET_OP } from "../replicator/schema/change-log.js";
import { getReplicationState } from "../replicator/schema/replication-state.js";
import { ZERO_VERSION_COLUMN_NAME } from "../replicator/schema/constants.js";
class Snapshotter {
  #lc;
  #dbFile;
  #appID;
  #pageCacheSizeKib;
  #curr;
  #prev;
  constructor(lc, dbFile, { appID }, pageCacheSizeKib) {
    this.#lc = lc;
    this.#dbFile = dbFile;
    this.#appID = appID;
    this.#pageCacheSizeKib = pageCacheSizeKib;
  }
  /**
   * 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 === void 0, "Already initialized");
    this.#curr = Snapshot.create(
      this.#lc,
      this.#dbFile,
      this.#appID,
      this.#pageCacheSizeKib
    );
    this.#lc.debug?.(`Initial snapshot at version ${this.#curr.version}`);
    return this;
  }
  initialized() {
    return this.#curr !== void 0;
  }
  /** Returns the current snapshot. Asserts if {@link initialized()} is false. */
  current() {
    assert(this.#curr !== void 0, "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 !== void 0, "Snapshotter has not been initialized");
    const next = this.#prev ? this.#prev.resetToHead() : Snapshot.create(
      this.#lc,
      this.#curr.db.db.name,
      this.#appID,
      this.#pageCacheSizeKib
    );
    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");
  }
}
class ResetPipelinesSignal extends Error {
  name = "ResetPipelinesSignal";
  constructor(msg) {
    super(msg);
  }
}
class Snapshot {
  static create(lc, dbFile, appID, pageCacheSizeKib) {
    const conn = new Database(lc, dbFile);
    conn.pragma("synchronous = OFF");
    if (pageCacheSizeKib !== void 0) {
      conn.pragma(`cache_size = -${pageCacheSizeKib}`);
    }
    const [{ journal_mode: mode }] = conn.pragma("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;
  constructor(db, appID) {
    db.beginConcurrent();
    const { stateVersion } = getReplicationState(db);
    this.db = db;
    this.#appID = appID;
    this.version = stateVersion;
  }
  numChangesSince(prevVersion) {
    const { count } = this.db.get(
      'SELECT COUNT(*) AS count FROM "_zero.changeLog2" WHERE stateVersion > ?',
      prevVersion
    );
    return count;
  }
  changesSince(prevVersion) {
    const cached = this.db.statementCache.get(
      `SELECT "stateVersion", "table", "rowKey", "op" FROM "_zero.changeLog2"
         WHERE "stateVersion" > ? ORDER BY "stateVersion" ASC, "pos" ASC`
    );
    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 {
      return cached.statement.get(Object.values(key));
    } finally {
      this.db.statementCache.return(cached);
    }
  }
  getRows(table, keys, row) {
    const validKeys = keys.filter(
      (key) => key.every((column) => row[column] !== null && row[column] !== void 0)
    );
    if (validKeys.length === 0) {
      return [];
    }
    const conds = validKeys.map((key) => 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.map((cond) => cond.join(" AND ")).join(" OR ")}`
    );
    cached.statement.safeIntegers(true);
    try {
      return cached.statement.all(
        validKeys.flatMap((key) => key.map((column) => row[column]))
      );
    } finally {
      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 {
        changes.return?.(void 0);
      } finally {
        done();
      }
    };
    return {
      next: () => {
        try {
          for (; ; ) {
            const { value, done: done2 } = changes.next();
            if (done2) {
              cleanup();
              return { value, done: true };
            }
            const { table, rowKey, op, stateVersion } = parse(value, changeLogEntrySchema);
            if (op === RESET_OP) {
              throw new ResetPipelinesSignal(
                `schema for table ${table} has changed`
              );
            }
            if (op === TRUNCATE_OP) {
              throw new ResetPipelinesSignal(
                `table ${table} has been truncated`
              );
            }
            const { tableSpec, zqlSpec } = must(this.tables.get(table));
            assert(rowKey !== null, "rowKey must be present for row changes");
            const nextValue = op === SET_OP ? this.curr.getRow(tableSpec, rowKey) : null;
            let prevValues;
            if (nextValue) {
              prevValues = this.prev.getRows(
                tableSpec,
                tableSpec.uniqueKeys,
                nextValue
              );
            } else {
              const prevValue = this.prev.getRow(tableSpec, rowKey);
              prevValues = prevValue ? [prevValue] : [];
            }
            if (nextValue === void 0) {
              throw new Error(
                `Missing value for ${table} ${stringify(rowKey)}`
              );
            }
            this.checkThatDiffIsValid(stateVersion, op, prevValues, nextValue);
            if (prevValues.length === 0 && nextValue === null) {
              continue;
            }
            if (table === this.#permissionsTable && prevValues.find(
              (prevValue) => prevValue.permissions !== nextValue.permissions
            )) {
              throw new ResetPipelinesSignal(
                `Permissions have changed ${prevValues.find(
                  (prevValue) => prevValue.permissions !== nextValue.permissions
                ).hash} => ${nextValue.hash}`
              );
            }
            return {
              value: {
                table,
                prevValues: prevValues.map(
                  (prevValue) => fromSQLiteTypes(zqlSpec, prevValue, table)
                ),
                nextValue: nextValue ? fromSQLiteTypes(zqlSpec, nextValue, table) : null,
                rowKey
              }
            };
          }
        } catch (e) {
          cleanup();
          throw e;
        }
      },
      return: (value) => {
        cleanup();
        return { value, done: true };
      }
    };
  }
  checkThatDiffIsValid(stateVersion, op, prevValues, nextValue) {
    if (stateVersion > this.curr.version) {
      throw new InvalidDiffError(
        `Diff is no longer valid. curr db has advanced past ${this.curr.version}`
      );
    }
    if (prevValues.findIndex(
      (prevValue) => (prevValue[ZERO_VERSION_COLUMN_NAME] ?? "~") > this.prev.version
    ) !== -1) {
      throw new InvalidDiffError(
        `Diff is no longer valid. prev db has advanced past ${this.prev.version}.`
      );
    }
    if (op === SET_OP && nextValue[ZERO_VERSION_COLUMN_NAME] !== stateVersion) {
      throw new InvalidDiffError(
        "Diff is no longer valid. curr db has advanced."
      );
    }
  }
}
class InvalidDiffError extends Error {
  constructor(msg) {
    super(msg);
  }
}
export {
  InvalidDiffError,
  ResetPipelinesSignal,
  Snapshotter
};
//# sourceMappingURL=snapshotter.js.map
