import { SqliteError } from "@rocicorp/zero-sqlite3";
import { AbortError } from "../../../../shared/src/abort-error.js";
import { assert, unreachable } from "../../../../shared/src/asserts.js";
import { stringify } from "../../../../shared/src/bigint-json.js";
import { must } from "../../../../shared/src/must.js";
import { createLiteTableStatement, liteColumnDef, createLiteIndexStatement } from "../../db/create.js";
import { computeZqlSpecs, listTables, listIndexes } from "../../db/lite-tables.js";
import { mapPostgresToLite, mapPostgresToLiteColumn, mapPostgresToLiteIndex } from "../../db/pg-to-lite.js";
import { JSON_PARSED, liteRow } from "../../types/lite.js";
import { liteTableName } from "../../types/names.js";
import { id } from "../../types/sql.js";
import { ChangeLog, DEL_OP, SET_OP } from "./schema/change-log.js";
import { ColumnMetadataStore } from "./schema/column-metadata.js";
import { updateReplicationWatermark } from "./schema/replication-state.js";
import { TableMetadataTracker } from "./schema/table-metadata.js";
import { ZERO_VERSION_COLUMN_NAME } from "./schema/constants.js";
class ChangeProcessor {
  #db;
  #changeLog;
  #tableMetadata;
  #mode;
  #failService;
  // The TransactionProcessor lazily loads table specs into this Map,
  // and reloads them after a schema change. It is cached here to avoid
  // reading them from the DB on every transaction.
  #tableSpecs = /* @__PURE__ */ new Map();
  #currentTx = null;
  #failure;
  constructor(db, mode, failService) {
    this.#db = db;
    this.#changeLog = new ChangeLog(db.db);
    this.#tableMetadata = new TableMetadataTracker(db.db);
    this.#mode = mode;
    this.#failService = failService;
  }
  #fail(lc, err) {
    if (!this.#failure) {
      this.#currentTx?.abort(lc);
      this.#failure = ensureError(err);
      if (!(err instanceof AbortError)) {
        lc.error?.("Message Processing failed:", this.#failure);
        this.#failService(lc, this.#failure);
      }
    }
  }
  abort(lc) {
    this.#fail(lc, new AbortError());
  }
  /** @return If a transaction was committed. */
  processMessage(lc, downstream) {
    const [type, message] = downstream;
    if (this.#failure) {
      lc.debug?.(`Dropping ${message.tag}`);
      return null;
    }
    try {
      const watermark = type === "begin" ? downstream[2].commitWatermark : type === "commit" ? downstream[2].watermark : void 0;
      return this.#processMessage(lc, message, watermark);
    } catch (e) {
      this.#fail(lc, e);
    }
    return null;
  }
  #beginTransaction(lc, commitVersion, jsonFormat) {
    const start = Date.now();
    for (let i = 0; ; i++) {
      try {
        return new TransactionProcessor(
          lc,
          this.#db,
          this.#mode,
          this.#changeLog,
          this.#tableMetadata,
          this.#tableSpecs,
          commitVersion,
          jsonFormat
        );
      } catch (e) {
        if (e instanceof SqliteError && e.code === "SQLITE_BUSY") {
          lc.warn?.(
            `SQLITE_BUSY for ${Date.now() - start} ms (attempt ${i + 1}). This is only expected if litestream is performing a large checkpoint.`,
            e
          );
          continue;
        }
        throw e;
      }
    }
  }
  /** @return If a transaction was committed. */
  #processMessage(lc, msg, watermark) {
    if (msg.tag === "begin") {
      if (this.#currentTx) {
        throw new Error(`Already in a transaction ${stringify(msg)}`);
      }
      this.#currentTx = this.#beginTransaction(
        lc,
        must(watermark),
        msg.json ?? JSON_PARSED
      );
      return null;
    }
    const tx = this.#currentTx;
    if (!tx) {
      throw new Error(
        `Received message outside of transaction: ${stringify(msg)}`
      );
    }
    if (msg.tag === "commit") {
      this.#currentTx = null;
      assert(watermark, "watermark is required for commit messages");
      const { schemaUpdated, changeLogUpdated } = tx.processCommit(
        msg,
        watermark
      );
      return { watermark, schemaUpdated, changeLogUpdated };
    }
    if (msg.tag === "rollback") {
      this.#currentTx?.abort(lc);
      this.#currentTx = null;
      return null;
    }
    switch (msg.tag) {
      case "insert":
        tx.processInsert(msg);
        break;
      case "update":
        tx.processUpdate(msg);
        break;
      case "delete":
        tx.processDelete(msg);
        break;
      case "truncate":
        tx.processTruncate(msg);
        break;
      case "create-table":
        tx.processCreateTable(msg);
        break;
      case "rename-table":
        tx.processRenameTable(msg);
        break;
      case "update-table-metadata":
        tx.processTableMetadata(msg);
        break;
      case "add-column":
        tx.processAddColumn(msg);
        break;
      case "update-column":
        tx.processUpdateColumn(msg);
        break;
      case "drop-column":
        tx.processDropColumn(msg);
        break;
      case "drop-table":
        tx.processDropTable(msg);
        break;
      case "create-index":
        tx.processCreateIndex(msg);
        break;
      case "drop-index":
        tx.processDropIndex(msg);
        break;
      case "backfill":
        tx.processBackfill(msg);
        break;
      case "backfill-completed":
        tx.processBackfillCompleted(msg);
        break;
      default:
        unreachable();
    }
    return null;
  }
}
class TransactionProcessor {
  #lc;
  #startMs;
  #db;
  #mode;
  #version;
  #changeLog;
  #tableMetadata;
  #tableSpecs;
  #jsonFormat;
  #columnMetadata;
  #pos = 0;
  #schemaChanged = false;
  #numChangeLogEntries = 0;
  constructor(lc, db, mode, changeLog, tableMetadata, tableSpecs, commitVersion, jsonFormat) {
    this.#startMs = Date.now();
    this.#mode = mode;
    this.#jsonFormat = jsonFormat;
    switch (mode) {
      case "serving":
        db.beginConcurrent();
        break;
      case "backup":
        db.beginImmediate();
        break;
      case "initial-sync":
        break;
      default:
        unreachable();
    }
    this.#db = db;
    this.#version = commitVersion;
    this.#lc = lc.withContext("version", commitVersion);
    this.#changeLog = changeLog;
    this.#tableMetadata = tableMetadata;
    this.#tableSpecs = tableSpecs;
    this.#columnMetadata = must(ColumnMetadataStore.getInstance(db.db));
    if (this.#tableSpecs.size === 0) {
      this.#reloadTableSpecs();
    }
  }
  #reloadTableSpecs() {
    this.#tableSpecs.clear();
    const zqlSpecs = computeZqlSpecs(this.#lc, this.#db.db, {
      includeBackfillingColumns: true
    });
    for (let spec of listTables(this.#db.db)) {
      if (!spec.primaryKey) {
        spec = {
          ...spec,
          primaryKey: [
            ...zqlSpecs.get(spec.name)?.tableSpec.primaryKey ?? []
          ]
        };
      }
      this.#tableSpecs.set(spec.name, spec);
    }
  }
  #tableSpec(name) {
    return must(this.#tableSpecs.get(name), `Unknown table ${name}`);
  }
  #getKey({ row, numCols }, { relation }) {
    const keyColumns = relation.rowKey.type !== "full" ? relation.rowKey.columns : this.#tableSpec(liteTableName(relation)).primaryKey;
    if (!keyColumns?.length) {
      throw new Error(
        `Cannot replicate table "${relation.name}" without a PRIMARY KEY or UNIQUE INDEX`
      );
    }
    if (numCols === keyColumns.length) {
      return row;
    }
    const key = {};
    for (const col of keyColumns) {
      key[col] = row[col];
    }
    return key;
  }
  processInsert(insert) {
    const table = liteTableName(insert.relation);
    const tableSpec = this.#tableSpec(table);
    const newRow = liteRow(insert.new, tableSpec, this.#jsonFormat);
    this.#upsert(table, {
      ...newRow.row,
      [ZERO_VERSION_COLUMN_NAME]: this.#version
    });
    if (insert.relation.rowKey.columns.length === 0) {
      return;
    }
    const key = this.#getKey(newRow, insert);
    this.#logSetOp(table, key, getBackfilledColumns(newRow.row, tableSpec));
  }
  #upsert(table, row) {
    const columns = Object.keys(row).map((c) => id(c));
    this.#db.run(
      `
      INSERT OR REPLACE INTO ${id(table)} (${columns.join(",")})
        VALUES (${Array.from({ length: columns.length }).fill("?").join(",")})
      `,
      Object.values(row)
    );
  }
  // Updates by default are applied as UPDATE commands to support partial
  // row specifications from the change source. In particular, this is needed
  // to handle updates for which unchanged TOASTed values are not sent:
  //
  // https://www.postgresql.org/docs/current/protocol-logicalrep-message-formats.html#PROTOCOL-LOGICALREP-MESSAGE-FORMATS-TUPLEDATA
  //
  // However, in certain cases an UPDATE may be received for a row that
  // was not initially synced, such as when, an existing table is added
  // to the app's publication.
  //
  // In order to facilitate "resumptive" replication, the logic falls back to
  // an INSERT if the update did not change any rows.
  processUpdate(update) {
    const table = liteTableName(update.relation);
    const tableSpec = this.#tableSpec(table);
    const newRow = liteRow(update.new, tableSpec, this.#jsonFormat);
    const row = { ...newRow.row, [ZERO_VERSION_COLUMN_NAME]: this.#version };
    const oldKey = update.key ? this.#getKey(
      liteRow(update.key, this.#tableSpec(table), this.#jsonFormat),
      update
    ) : null;
    const newKey = this.#getKey(newRow, update);
    if (oldKey) {
      this.#logDeleteOp(table, oldKey, tableSpec.backfilling);
    }
    this.#logSetOp(table, newKey, getBackfilledColumns(newRow.row, tableSpec));
    const currKey = oldKey ?? newKey;
    const conds = Object.keys(currKey).map((col) => `${id(col)}=?`);
    const setExprs = Object.keys(row).map((col) => `${id(col)}=?`);
    const { changes } = this.#db.run(
      `
      UPDATE ${id(table)}
        SET ${setExprs.join(",")}
        WHERE ${conds.join(" AND ")}
      `,
      [...Object.values(row), ...Object.values(currKey)]
    );
    if (changes === 0) {
      this.#upsert(table, row);
    }
  }
  processDelete(del) {
    const table = liteTableName(del.relation);
    const tableSpec = this.#tableSpec(table);
    const rowKey = this.#getKey(
      liteRow(del.key, tableSpec, this.#jsonFormat),
      del
    );
    this.#delete(table, rowKey);
    this.#logDeleteOp(table, rowKey, tableSpec.backfilling);
  }
  #delete(table, rowKey) {
    const conds = Object.keys(rowKey).map((col) => `${id(col)}=?`);
    this.#db.run(
      `DELETE FROM ${id(table)} WHERE ${conds.join(" AND ")}`,
      Object.values(rowKey)
    );
  }
  processTruncate(truncate) {
    for (const relation of truncate.relations) {
      const table = liteTableName(relation);
      this.#db.run(`DELETE FROM ${id(table)}`);
      this.#logTruncateOp(table);
    }
  }
  processCreateTable(create) {
    if (create.metadata) {
      this.#tableMetadata.set(create.spec, create.metadata);
    }
    const table = mapPostgresToLite(create.spec);
    this.#db.db.exec(createLiteTableStatement(table));
    for (const [colName, colSpec] of Object.entries(create.spec.columns)) {
      this.#columnMetadata.insert(
        table.name,
        colName,
        colSpec,
        create.backfill?.[colName]
      );
    }
    if (Object.keys(create.backfill ?? {}).length === Object.keys(create.spec.columns).length) {
      this.#reloadTableSpecs();
    } else {
      this.#logResetOp(table.name);
    }
    this.#lc.info?.(create.tag, table.name);
  }
  processTableMetadata(msg) {
    this.#tableMetadata.set(msg.table, msg.new);
  }
  processRenameTable(rename) {
    this.#tableMetadata.rename(rename.old, rename.new);
    const oldName = liteTableName(rename.old);
    const newName = liteTableName(rename.new);
    this.#db.db.exec(`ALTER TABLE ${id(oldName)} RENAME TO ${id(newName)}`);
    this.#columnMetadata.renameTable(oldName, newName);
    this.#bumpVersions(newName);
    this.#logResetOp(oldName);
    this.#lc.info?.(rename.tag, oldName, newName);
  }
  processAddColumn(msg) {
    if (msg.tableMetadata) {
      this.#tableMetadata.set(msg.table, msg.tableMetadata);
    }
    const table = liteTableName(msg.table);
    const { name } = msg.column;
    const spec = mapPostgresToLiteColumn(table, msg.column);
    this.#db.db.exec(
      `ALTER TABLE ${id(table)} ADD ${id(name)} ${liteColumnDef(spec)}`
    );
    this.#columnMetadata.insert(table, name, msg.column.spec, msg.backfill);
    if (msg.backfill) {
      this.#reloadTableSpecs();
    } else {
      this.#bumpVersions(table);
    }
    this.#lc.info?.(msg.tag, table, msg.column);
  }
  processUpdateColumn(msg) {
    const table = liteTableName(msg.table);
    let oldName = msg.old.name;
    const newName = msg.new.name;
    const oldSpec = mapPostgresToLiteColumn(table, msg.old, "ignore-default");
    const newSpec = mapPostgresToLiteColumn(table, msg.new, "ignore-default");
    if (oldName === newName && oldSpec.dataType === newSpec.dataType) {
      this.#lc.info?.(msg.tag, "no thing to update", oldSpec, newSpec);
      return;
    }
    if (oldSpec.dataType !== newSpec.dataType) {
      const indexes = listIndexes(this.#db.db).filter(
        (idx) => idx.tableName === table && oldName in idx.columns
      );
      const stmts = indexes.map((idx) => `DROP INDEX IF EXISTS ${id(idx.name)};`);
      const tmpName = `tmp.${newName}`;
      stmts.push(`
        ALTER TABLE ${id(table)} ADD ${id(tmpName)} ${liteColumnDef(newSpec)};
        UPDATE ${id(table)} SET ${id(tmpName)} = ${id(oldName)};
        ALTER TABLE ${id(table)} DROP ${id(oldName)};
        `);
      for (const idx of indexes) {
        idx.columns[tmpName] = idx.columns[oldName];
        delete idx.columns[oldName];
        stmts.push(createLiteIndexStatement(idx));
      }
      this.#db.db.exec(stmts.join(""));
      oldName = tmpName;
    }
    if (oldName !== newName) {
      this.#db.db.exec(
        `ALTER TABLE ${id(table)} RENAME ${id(oldName)} TO ${id(newName)}`
      );
    }
    this.#columnMetadata.update(
      table,
      msg.old.name,
      msg.new.name,
      msg.new.spec
    );
    this.#bumpVersions(table);
    this.#lc.info?.(msg.tag, table, msg.new);
  }
  processDropColumn(msg) {
    const table = liteTableName(msg.table);
    const { column } = msg;
    this.#db.db.exec(`ALTER TABLE ${id(table)} DROP ${id(column)}`);
    this.#columnMetadata.deleteColumn(table, column);
    this.#bumpVersions(table);
    this.#lc.info?.(msg.tag, table, column);
  }
  processDropTable(drop) {
    this.#tableMetadata.drop(drop.id);
    const name = liteTableName(drop.id);
    this.#db.db.exec(`DROP TABLE IF EXISTS ${id(name)}`);
    this.#columnMetadata.deleteTable(name);
    this.#logResetOp(name);
    this.#lc.info?.(drop.tag, name);
  }
  processCreateIndex(create) {
    const index = mapPostgresToLiteIndex(create.spec);
    this.#db.db.exec(createLiteIndexStatement(index));
    const tableSpec = must(this.#tableSpecs.get(index.tableName));
    if ((tableSpec.backfilling ?? []).length === Object.entries(tableSpec.columns).length - 1) {
      this.#reloadTableSpecs();
    } else {
      this.#logResetOp(index.tableName);
    }
    this.#lc.info?.(create.tag, index.name);
  }
  processDropIndex(drop) {
    const name = liteTableName(drop.id);
    this.#db.db.exec(`DROP INDEX IF EXISTS ${id(name)}`);
    this.#lc.info?.(drop.tag, name);
  }
  #bumpVersions(table) {
    this.#db.run(
      `UPDATE ${id(table)} SET ${id(ZERO_VERSION_COLUMN_NAME)} = ?`,
      this.#version
    );
    this.#logResetOp(table);
  }
  /**
   * @param backfilledColumns `backfilling` columns for which values were set
   */
  #logSetOp(table, key, backfilledColumns) {
    if (this.#mode === "serving" || backfilledColumns !== void 0) {
      this.#changeLog.logSetOp(
        this.#version,
        this.#pos++,
        table,
        key,
        backfilledColumns
      );
      this.#numChangeLogEntries++;
    }
  }
  #logDeleteOp(table, key, backfilling) {
    if (this.#mode === "serving" || backfilling?.length) {
      this.#changeLog.logDeleteOp(this.#version, this.#pos++, table, key);
      this.#numChangeLogEntries++;
    }
  }
  #logTruncateOp(table) {
    if (this.#mode === "serving") {
      this.#changeLog.logTruncateOp(this.#version, table);
      this.#numChangeLogEntries++;
    }
  }
  #logResetOp(table) {
    this.#schemaChanged = true;
    if (this.#mode === "serving") {
      this.#changeLog.logResetOp(this.#version, table);
      this.#numChangeLogEntries++;
    }
    this.#reloadTableSpecs();
  }
  processBackfill({ relation, watermark, columns, rowValues }) {
    const tableName = liteTableName(relation);
    const tableSpec = must(this.#tableSpecs.get(tableName));
    const rowKeyCols = relation.rowKey.columns;
    const cols = [...rowKeyCols, ...columns];
    const insertColsStr = [...cols, ZERO_VERSION_COLUMN_NAME].map(id).join(",");
    const qMarks = Array.from({ length: cols.length + 1 }, () => "?").join(",");
    const rowKeyColsStr = rowKeyCols.map(id).join(",");
    let backfilled = 0;
    let skipped = 0;
    for (const v of rowValues) {
      const row = liteRow(
        Object.fromEntries(cols.map((c, i) => [c, v[i]])),
        tableSpec,
        this.#jsonFormat
      );
      const rowKey = this.#getKey(row, { relation });
      const rowOp = this.#changeLog.getLatestRowOp(tableName, rowKey);
      if (rowOp?.op === DEL_OP && rowOp.stateVersion > watermark) {
        skipped++;
        continue;
      }
      const updates = rowOp?.op === SET_OP ? cols.filter(
        (c) => (rowOp.backfillingColumnVersions[c] ?? "") <= watermark
      ) : cols;
      if (updates.length === 0) {
        skipped++;
        continue;
      }
      const updateStmts = updates.map((col) => `${id(col)}=excluded.${id(col)}`);
      this.#db.run(
        /*sql*/
        `
        INSERT INTO ${id(tableName)} (${insertColsStr}) VALUES (${qMarks})
          ON CONFLICT (${rowKeyColsStr})
          DO UPDATE SET ${updateStmts.join(",")};
      `,
        ...Object.values(row.row),
        watermark
        // the _0_version for new rows (i.e. table backfill)
      );
      backfilled++;
    }
    this.#lc.debug?.(
      `backfilled ${backfilled} rows (skipped ${skipped}) into ${tableName}`
    );
  }
  processBackfillCompleted({ relation, columns }) {
    const tableName = liteTableName(relation);
    const rowKeyCols = relation.rowKey.columns;
    const cols = [...rowKeyCols, ...columns];
    const columnMetadata = must(ColumnMetadataStore.getInstance(this.#db.db));
    for (const col of cols) {
      columnMetadata.clearBackfilling(tableName, col);
    }
    this.#bumpVersions(tableName);
    this.#lc.info?.(`finished backfilling ${tableName}`);
  }
  processCommit(commit, watermark) {
    if (watermark !== this.#version) {
      throw new Error(
        `'commit' version ${watermark} does not match 'begin' version ${this.#version}: ${stringify(commit)}`
      );
    }
    updateReplicationWatermark(this.#db, watermark);
    if (this.#schemaChanged) {
      const start = Date.now();
      this.#db.db.pragma("optimize");
      this.#lc.info?.(
        `PRAGMA optimized after schema change (${Date.now() - start} ms)`
      );
    }
    if (this.#mode !== "initial-sync") {
      this.#db.commit();
    }
    const elapsedMs = Date.now() - this.#startMs;
    this.#lc.debug?.(`Committed tx@${this.#version} (${elapsedMs} ms)`);
    return {
      schemaUpdated: this.#schemaChanged,
      changeLogUpdated: this.#numChangeLogEntries > 0
    };
  }
  abort(lc) {
    lc.info?.(`aborting transaction ${this.#version}`);
    this.#db.rollback();
  }
}
function getBackfilledColumns(row, { backfilling }) {
  if (!backfilling?.length) {
    return void 0;
  }
  return backfilling.filter((col) => col in row);
}
function ensureError(err) {
  if (err instanceof Error) {
    return err;
  }
  const error = new Error();
  error.cause = err;
  return error;
}
export {
  ChangeProcessor
};
//# sourceMappingURL=change-processor.js.map
