import { PG_OBJECT_IN_USE, PG_ADMIN_SHUTDOWN } from "@drdgvhbh/postgres-error-codes";
import postgres from "postgres";
import { AbortError } from "../../../../../shared/src/abort-error.js";
import { stringify } from "../../../../../shared/src/bigint-json.js";
import { deepEqual } from "../../../../../shared/src/json.js";
import { must } from "../../../../../shared/src/must.js";
import { mapValues } from "../../../../../shared/src/objects.js";
import { promiseVoid } from "../../../../../shared/src/resolved-promises.js";
import { symmetricDifferences, intersection, equals } from "../../../../../shared/src/set-utils.js";
import { sleep } from "../../../../../shared/src/sleep.js";
import { parse } from "../../../../../shared/src/valita.js";
import { Database } from "../../../../../zqlite/src/db.js";
import { mapPostgresToLiteColumn, UnsupportedColumnDefaultError } from "../../../db/pg-to-lite.js";
import { runTx } from "../../../db/run-transaction.js";
import { StatementRunner } from "../../../db/statements.js";
import { pgClient } from "../../../types/pg.js";
import { upstreamSchema } from "../../../types/shards.js";
import { majorVersionFromString, majorVersionToString } from "../../../types/state-version.js";
import { AutoResetSignal } from "../../change-streamer/schema/tables.js";
import { getSubscriptionStateAndContext } from "../../replicator/schema/replication-state.js";
import { BackfillManager } from "../common/backfill-manager.js";
import { ChangeStreamMultiplexer } from "../common/change-stream-multiplexer.js";
import { initReplica } from "../common/replica-schema.js";
import { streamBackfill } from "./backfill-stream.js";
import { initialSync } from "./initial-sync.js";
import { subscribe } from "./logical-replication/stream.js";
import { fromBigInt, toStateVersionString } from "./lsn.js";
import { replicationEventSchema } from "./schema/ddl.js";
import { updateShardSchema } from "./schema/init.js";
import { getPublicationInfo } from "./schema/published.js";
import { getReplicaAtVersion, internalPublicationPrefix, dropShard, getInternalShardConfig, replicationSlotExpression, legacyReplicationSlot, replicaIdentitiesForTablesWithoutPrimaryKeys } from "./schema/shard.js";
import { validate } from "./schema/validation.js";
async function initializePostgresChangeSource(lc, upstreamURI, shard, replicaDbFile, syncOptions, context) {
  await initReplica(
    lc,
    `replica-${shard.appID}-${shard.shardNum}`,
    replicaDbFile,
    (log, tx) => initialSync(log, shard, tx, upstreamURI, syncOptions, context)
  );
  const replica = new Database(lc, replicaDbFile);
  const subscriptionState = getSubscriptionStateAndContext(
    new StatementRunner(replica)
  );
  replica.close();
  const db = pgClient(lc, upstreamURI);
  try {
    const upstreamReplica = await checkAndUpdateUpstream(
      lc,
      db,
      shard,
      subscriptionState
    );
    const changeSource = new PostgresChangeSource(
      lc,
      upstreamURI,
      shard,
      upstreamReplica,
      context
    );
    return { subscriptionState, changeSource };
  } finally {
    await db.end();
  }
}
async function checkAndUpdateUpstream(lc, sql, shard, {
  replicaVersion,
  publications: subscribed,
  initialSyncContext
}) {
  await updateShardSchema(lc, sql, shard, replicaVersion);
  const upstreamReplica = await getReplicaAtVersion(
    lc,
    sql,
    shard,
    replicaVersion,
    initialSyncContext
  );
  if (!upstreamReplica) {
    throw new AutoResetSignal(
      `No replication slot for replica at version ${replicaVersion}`
    );
  }
  const requested = [...shard.publications].sort();
  const replicated = upstreamReplica.publications.filter((p) => !p.startsWith(internalPublicationPrefix(shard))).sort();
  if (!deepEqual(requested, replicated)) {
    lc.warn?.(`Dropping shard to change publications to: [${requested}]`);
    await sql.unsafe(dropShard(shard.appID, shard.shardNum));
    throw new AutoResetSignal(
      `Requested publications [${requested}] do not match configured publications: [${replicated}]`
    );
  }
  if (!deepEqual(upstreamReplica.publications, subscribed)) {
    throw new AutoResetSignal(
      `Upstream publications [${upstreamReplica.publications}] do not match subscribed publications [${subscribed}]`
    );
  }
  const exists = await sql`
    SELECT pubname FROM pg_publication WHERE pubname IN ${sql(subscribed)};
  `.values();
  if (exists.length !== subscribed.length) {
    throw new AutoResetSignal(
      `Upstream publications [${exists.flat()}] do not contain all subscribed publications [${subscribed}]`
    );
  }
  const { slot } = upstreamReplica;
  const result = await sql`
    SELECT restart_lsn as "restartLSN", wal_status as "walStatus" FROM pg_replication_slots
      WHERE slot_name = ${slot}`;
  if (result.length === 0) {
    throw new AutoResetSignal(`replication slot ${slot} is missing`);
  }
  const [{ restartLSN, walStatus }] = result;
  if (restartLSN === null || walStatus === "lost") {
    throw new AutoResetSignal(
      `replication slot ${slot} has been invalidated for exceeding the max_slot_wal_keep_size`
    );
  }
  return upstreamReplica;
}
const MAX_LOW_PRIORITY_DELAY_MS = 1e3;
class PostgresChangeSource {
  #lc;
  #upstreamUri;
  #shard;
  #replica;
  #context;
  constructor(lc, upstreamUri, shard, replica, context) {
    this.#lc = lc.withContext("component", "change-source");
    this.#upstreamUri = upstreamUri;
    this.#shard = shard;
    this.#replica = replica;
    this.#context = context;
  }
  async startStream(clientWatermark, backfillRequests = []) {
    const db = pgClient(this.#lc, this.#upstreamUri);
    const { slot } = this.#replica;
    let cleanup = promiseVoid;
    try {
      ({ cleanup } = await this.#stopExistingReplicationSlotSubscribers(
        db,
        slot
      ));
      const config = await getInternalShardConfig(db, this.#shard);
      this.#lc.info?.(`starting replication stream@${slot}`);
      return await this.#startStream(
        db,
        slot,
        clientWatermark,
        config,
        backfillRequests
      );
    } finally {
      void cleanup.then(() => db.end());
    }
  }
  async #startStream(db, slot, clientWatermark, shardConfig, backfillRequests) {
    const clientStart = majorVersionFromString(clientWatermark) + 1n;
    const { messages, acks } = await subscribe(
      this.#lc,
      db,
      slot,
      [...shardConfig.publications],
      clientStart
    );
    const acker = new Acker(acks);
    const changes = new ChangeStreamMultiplexer(this.#lc, clientWatermark);
    const backfillManager = new BackfillManager(
      this.#lc,
      changes,
      (req) => streamBackfill(this.#lc, this.#upstreamUri, this.#replica, req)
    );
    changes.addProducers(messages, backfillManager).addListeners(backfillManager, acker);
    backfillManager.run(clientWatermark, backfillRequests);
    const changeMaker = new ChangeMaker(
      this.#lc,
      this.#shard,
      shardConfig,
      this.#replica.initialSchema,
      this.#upstreamUri
    );
    void (async () => {
      try {
        let reservation = null;
        let inTransaction = false;
        for await (const [lsn, msg] of messages) {
          if (msg.tag === "keepalive") {
            changes.pushStatus([
              "status",
              { ack: msg.shouldRespond },
              { watermark: majorVersionToString(lsn) }
            ]);
            if (!inTransaction && reservation?.lastWatermark) {
              changes.release(reservation.lastWatermark);
              reservation = null;
            }
            continue;
          }
          if (!reservation) {
            const res = changes.reserve("replication");
            typeof res === "string" || await res;
            reservation = {};
          }
          let lastChange;
          for (const change of await changeMaker.makeChanges(lsn, msg)) {
            await changes.push(change);
            lastChange = change;
          }
          switch (lastChange?.[0]) {
            case "begin":
              inTransaction = true;
              break;
            case "commit":
              inTransaction = false;
              reservation.lastWatermark = lastChange[2].watermark;
              if (messages.queued === 0 || changes.waiterDelay() > MAX_LOW_PRIORITY_DELAY_MS) {
                changes.release(reservation.lastWatermark);
                reservation = null;
              }
              break;
          }
        }
      } catch (e) {
        const err = translateError(e);
        if (err instanceof ShutdownSignal) {
          await this.#logCurrentReplicaInfo();
        }
        changes.fail(err);
      }
    })();
    this.#lc.info?.(
      `started replication stream@${slot} from ${clientWatermark} (replicaVersion: ${this.#replica.version})`
    );
    return {
      changes: changes.asSource(),
      acks: { push: (status) => acker.ack(status[2].watermark) }
    };
  }
  async #logCurrentReplicaInfo() {
    const db = pgClient(this.#lc, this.#upstreamUri);
    try {
      const replica = await getReplicaAtVersion(
        this.#lc,
        db,
        this.#shard,
        this.#replica.version
      );
      if (replica) {
        this.#lc.info?.(
          `Shutdown signal from replica@${this.#replica.version}: ${stringify(replica.subscriberContext)}`
        );
      }
    } catch (e) {
      this.#lc.warn?.(`error logging replica info`, e);
    } finally {
      await db.end();
    }
  }
  /**
   * Stops replication slots associated with this shard, and returns
   * a `cleanup` task that drops any slot other than the specified
   * `slotToKeep`.
   *
   * Note that replication slots created after `slotToKeep` (as indicated by
   * the timestamp suffix) are preserved, as those are newly syncing replicas
   * that will soon take over the slot.
   */
  async #stopExistingReplicationSlotSubscribers(db, slotToKeep) {
    const slotExpression = replicationSlotExpression(this.#shard);
    const legacySlotName = legacyReplicationSlot(this.#shard);
    const result = await runTx(db, async (sql) => {
      const result2 = await sql`
      SELECT slot_name as slot, pg_terminate_backend(active_pid) as terminated, active_pid as pid
        FROM pg_replication_slots 
        WHERE (slot_name LIKE ${slotExpression} OR slot_name = ${legacySlotName})
              AND slot_name <= ${slotToKeep}`;
      this.#lc.info?.(
        `terminated replication slots: ${JSON.stringify(result2)}`
      );
      const replicasTable = `${upstreamSchema(this.#shard)}.replicas`;
      const replicasBefore = await sql`
        SELECT slot, version, "initialSyncContext", "subscriberContext" 
          FROM ${sql(replicasTable)} ORDER BY slot`;
      if (result2.length === 0) {
        const shardSlots = await sql`
        SELECT slot_name as slot, active, active_pid as pid
          FROM pg_replication_slots
          WHERE slot_name LIKE ${slotExpression} OR slot_name = ${legacySlotName}
          ORDER BY slot_name`;
        this.#lc.warn?.(
          `slot ${slotToKeep} not found while cleaning subscribers`,
          { slots: shardSlots, replicas: replicasBefore }
        );
        throw new AbortError(
          `replication slot ${slotToKeep} is missing. A different replication-manager should now be running on a new replication slot.`
        );
      }
      this.#lc.info?.(
        `replicas before cleanup (slotToKeep=${slotToKeep}): ${JSON.stringify(
          replicasBefore
        )}`
      );
      await sql`
        DELETE FROM ${sql(replicasTable)} WHERE slot < ${slotToKeep}`;
      await sql`
        UPDATE ${sql(replicasTable)} 
          SET "subscriberContext" = ${this.#context}
          WHERE slot = ${slotToKeep}`;
      const replicasAfter = await sql`
      SELECT slot, version FROM ${sql(replicasTable)} ORDER BY slot`;
      this.#lc.info?.(
        `replicas after cleanup (slotToKeep=${slotToKeep}): ${JSON.stringify(
          replicasAfter
        )}`
      );
      return result2;
    });
    const pids = result.filter(({ pid }) => pid !== null).map(({ pid }) => pid);
    if (pids.length) {
      this.#lc.info?.(`signaled subscriber ${pids} to shut down`);
    }
    const otherSlots = result.filter(({ slot }) => slot !== slotToKeep).map(({ slot }) => slot);
    return {
      cleanup: otherSlots.length ? this.#dropReplicationSlots(db, otherSlots) : promiseVoid
    };
  }
  async #dropReplicationSlots(sql, slots) {
    this.#lc.info?.(`dropping other replication slot(s) ${slots}`);
    for (let i = 0; i < 5; i++) {
      try {
        await sql`
          SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots
            WHERE slot_name IN ${sql(slots)}
        `;
        this.#lc.info?.(`successfully dropped ${slots}`);
        return;
      } catch (e) {
        if (e instanceof postgres.PostgresError && e.code === PG_OBJECT_IN_USE) {
          this.#lc.debug?.(`attempt ${i + 1}: ${String(e)}`, e);
        } else {
          this.#lc.warn?.(`error dropping ${slots}`, e);
        }
        await sleep(1e3);
      }
    }
    this.#lc.warn?.(`maximum attempts exceeded dropping ${slots}`);
  }
}
class Acker {
  #acks;
  #waitingForDownstreamAck = null;
  constructor(acks) {
    this.#acks = acks;
  }
  onChange(change) {
    switch (change[0]) {
      case "status":
        const { watermark } = change[2];
        if (change[1].ack) {
          this.#expectDownstreamAck(watermark);
        } else {
          this.#ackIfDownstreamIsCaughtUp(watermark);
        }
        break;
      case "begin":
        if (!change[1].skipAck) {
          this.#expectDownstreamAck(change[2].commitWatermark);
        }
        break;
    }
  }
  #expectDownstreamAck(watermark) {
    this.#waitingForDownstreamAck = watermark;
  }
  ack(watermark) {
    if (this.#waitingForDownstreamAck && this.#waitingForDownstreamAck <= watermark) {
      this.#waitingForDownstreamAck = null;
    }
    this.#sendAck(watermark);
  }
  #ackIfDownstreamIsCaughtUp(watermark) {
    if (this.#waitingForDownstreamAck === null) {
      this.#sendAck(watermark);
    }
  }
  #sendAck(watermark) {
    const lsn = majorVersionFromString(watermark);
    this.#acks.push(lsn);
  }
}
const SET_REPLICA_IDENTITY_DELAY_MS = 50;
class ChangeMaker {
  #lc;
  #shardPrefix;
  #shardConfig;
  #initialSchema;
  #upstreamDB;
  #replicaIdentityTimer;
  #error;
  constructor(lc, { appID, shardNum }, shardConfig, initialSchema, upstreamURI) {
    this.#lc = lc;
    this.#shardPrefix = `${appID}/${shardNum}`;
    this.#shardConfig = shardConfig;
    this.#initialSchema = initialSchema;
    this.#upstreamDB = pgClient(lc, upstreamURI, {
      ["idle_timeout"]: 10,
      // only used occasionally
      connection: { ["application_name"]: "zero-schema-change-detector" }
    });
  }
  async makeChanges(lsn, msg) {
    if (this.#error) {
      this.#logError(this.#error);
      return [];
    }
    try {
      return await this.#makeChanges(msg);
    } catch (err) {
      this.#error = { lsn, msg, err, lastLogTime: 0 };
      this.#logError(this.#error);
      const message = `Unable to continue replication from LSN ${fromBigInt(lsn)}`;
      const errorDetails = { error: message };
      if (err instanceof UnsupportedSchemaChangeError) {
        errorDetails.reason = err.description;
        errorDetails.context = err.ddlUpdate.context;
      } else {
        errorDetails.reason = String(err);
      }
      return [
        ["rollback", { tag: "rollback" }],
        ["control", { tag: "reset-required", message, errorDetails }]
      ];
    }
  }
  #logError(error) {
    const { lsn, msg, err, lastLogTime } = error;
    const now = Date.now();
    if (now - lastLogTime > 6e4) {
      this.#lc.error?.(
        `Unable to continue replication from LSN ${fromBigInt(lsn)}: ${String(
          err
        )}`,
        err instanceof UnsupportedSchemaChangeError ? err.ddlUpdate.context : (
          // 'content' can be a large byte Buffer. Exclude it from logging output.
          { ...msg, content: void 0 }
        )
      );
      error.lastLogTime = now;
    }
  }
  // oxlint-disable-next-line require-await
  async #makeChanges(msg) {
    switch (msg.tag) {
      case "begin":
        return [
          [
            "begin",
            { ...msg, json: "s" },
            { commitWatermark: toStateVersionString(must(msg.commitLsn)) }
          ]
        ];
      case "delete": {
        if (!(msg.key ?? msg.old)) {
          throw new Error(
            `Invalid DELETE msg (missing key): ${stringify(msg)}`
          );
        }
        return [
          [
            "data",
            {
              ...msg,
              relation: makeRelation(msg.relation),
              // https://www.postgresql.org/docs/current/protocol-logicalrep-message-formats.html#PROTOCOL-LOGICALREP-MESSAGE-FORMATS-DELETE
              key: must(msg.old ?? msg.key)
            }
          ]
        ];
      }
      case "update": {
        return [
          [
            "data",
            {
              ...msg,
              relation: makeRelation(msg.relation),
              // https://www.postgresql.org/docs/current/protocol-logicalrep-message-formats.html#PROTOCOL-LOGICALREP-MESSAGE-FORMATS-UPDATE
              key: msg.old ?? msg.key
            }
          ]
        ];
      }
      case "insert":
        return [["data", { ...msg, relation: makeRelation(msg.relation) }]];
      case "truncate":
        return [["data", { ...msg, relations: msg.relations.map(makeRelation) }]];
      case "message":
        if (msg.prefix !== this.#shardPrefix) {
          this.#lc.debug?.("ignoring message for different shard", msg.prefix);
          return [];
        }
        return this.#handleCustomMessage(msg);
      case "commit":
        return [
          [
            "commit",
            msg,
            { watermark: toStateVersionString(must(msg.commitLsn)) }
          ]
        ];
      case "relation":
        return this.#handleRelation(msg);
      case "type":
        return [];
      // Nothing need be done for custom types.
      case "origin":
        return [];
      default:
        throw new Error(`Unexpected message type ${stringify(msg)}`);
    }
  }
  #preSchema;
  #handleCustomMessage(msg) {
    const event = this.#parseReplicationEvent(msg.content);
    clearTimeout(this.#replicaIdentityTimer);
    if (event.type === "ddlStart") {
      this.#preSchema = event.schema;
      return [];
    }
    const changes = this.#makeSchemaChanges(
      must(this.#preSchema, `ddlUpdate received without a ddlStart`),
      event
    ).map((change) => ["data", change]);
    this.#lc.withContext("tag", event.event.tag).withContext("query", event.context.query).info?.(`${changes.length} schema change(s)`, { changes });
    const replicaIdentities = replicaIdentitiesForTablesWithoutPrimaryKeys(
      event.schema
    );
    if (replicaIdentities) {
      this.#replicaIdentityTimer = setTimeout(async () => {
        try {
          await replicaIdentities.apply(this.#lc, this.#upstreamDB);
        } catch (err) {
          this.#lc.warn?.(`error setting replica identities`, err);
        }
      }, SET_REPLICA_IDENTITY_DELAY_MS);
    }
    return changes;
  }
  /**
   *  A note on operation order:
   *
   * Postgres will drop related indexes when columns are dropped,
   * but SQLite will error instead (https://sqlite.org/forum/forumpost/2e62dba69f?t=c&hist).
   * The current workaround is to drop indexes first.
   *
   * Also note that although it should not be possible to both rename and
   * add/drop tables/columns in a single statement, the operations are
   * ordered to handle that possibility, by always dropping old entities,
   * then modifying kept entities, and then adding new entities.
   *
   * Thus, the order of replicating DDL updates is:
   * - drop indexes
   * - drop tables
   * - alter tables
   *   - drop columns
   *   - alter columns
   *   - add columns
   * - create tables
   * - create indexes
   *
   * In the future the replication logic should be improved to handle this
   * behavior in SQLite by dropping dependent indexes manually before dropping
   * columns. This, for example, would be needed to properly support changing
   * the type of a column that's indexed.
   */
  #makeSchemaChanges(preSchema, update) {
    try {
      const [prevTbl, prevIdx] = specsByID(preSchema);
      const [nextTbl, nextIdx] = specsByID(update.schema);
      const changes = [];
      for (const table of nextTbl.values()) {
        validate(this.#lc, table);
      }
      const [droppedIdx, createdIdx] = symmetricDifferences(prevIdx, nextIdx);
      for (const id of droppedIdx) {
        const { schema, name } = must(prevIdx.get(id));
        changes.push({ tag: "drop-index", id: { schema, name } });
      }
      const [droppedTbl, createdTbl] = symmetricDifferences(prevTbl, nextTbl);
      for (const id of droppedTbl) {
        const { schema, name } = must(prevTbl.get(id));
        changes.push({ tag: "drop-table", id: { schema, name } });
      }
      const tables = intersection(prevTbl, nextTbl);
      for (const id of tables) {
        changes.push(
          ...this.#getTableChanges(
            must(prevTbl.get(id)),
            must(nextTbl.get(id)),
            update.event.tag
          )
        );
      }
      for (const id of createdTbl) {
        const spec = must(nextTbl.get(id));
        const createTable = {
          tag: "create-table",
          spec,
          metadata: getMetadata(spec)
        };
        if (!update.event.tag.startsWith("CREATE")) {
          createTable.backfill = mapValues(spec.columns, ({ pos: attNum }) => ({
            attNum
          }));
        }
        changes.push(createTable);
      }
      for (const id of createdIdx) {
        const spec = must(nextIdx.get(id));
        changes.push({ tag: "create-index", spec });
      }
      return changes;
    } catch (e) {
      throw new UnsupportedSchemaChangeError(String(e), update, { cause: e });
    }
  }
  #getTableChanges(oldTable, newTable, ddlTag) {
    const changes = [];
    if (oldTable.schema !== newTable.schema || oldTable.name !== newTable.name) {
      changes.push({
        tag: "rename-table",
        old: { schema: oldTable.schema, name: oldTable.name },
        new: { schema: newTable.schema, name: newTable.name }
      });
    }
    const oldMetadata = getMetadata(oldTable);
    const newMetadata = getMetadata(newTable);
    if (!deepEqual(oldMetadata, newMetadata)) {
      changes.push({
        tag: "update-table-metadata",
        table: { schema: newTable.schema, name: newTable.name },
        old: oldMetadata,
        new: newMetadata
      });
    }
    const table = { schema: newTable.schema, name: newTable.name };
    const oldColumns = columnsByID(oldTable.columns);
    const newColumns = columnsByID(newTable.columns);
    const [dropped, added] = symmetricDifferences(oldColumns, newColumns);
    for (const id of dropped) {
      const { name: column } = must(oldColumns.get(id));
      changes.push({ tag: "drop-column", table, column });
    }
    const both = intersection(oldColumns, newColumns);
    for (const id of both) {
      const { name: oldName, ...oldSpec } = must(oldColumns.get(id));
      const { name: newName, ...newSpec } = must(newColumns.get(id));
      if (oldName !== newName || oldSpec.dataType !== newSpec.dataType || oldSpec.notNull !== newSpec.notNull) {
        changes.push({
          tag: "update-column",
          table,
          old: { name: oldName, spec: oldSpec },
          new: { name: newName, spec: newSpec }
        });
      }
    }
    const alwaysBackfill = ddlTag === "ALTER PUBLICATION";
    for (const id of added) {
      const { name, ...spec } = must(newColumns.get(id));
      const column = { name, spec };
      const addColumn = {
        tag: "add-column",
        table,
        column,
        tableMetadata: getMetadata(newTable)
      };
      if (alwaysBackfill) {
        addColumn.column.spec.dflt = null;
        addColumn.backfill = { attNum: spec.pos };
      } else {
        try {
          mapPostgresToLiteColumn(table.name, column);
        } catch (e) {
          if (!(e instanceof UnsupportedColumnDefaultError)) {
            throw e;
          }
          this.#lc.info?.(
            `Backfilling column ${table.name}.${name}: ${String(e)}`
          );
          addColumn.column.spec.dflt = null;
          addColumn.backfill = { attNum: spec.pos };
        }
      }
      changes.push(addColumn);
    }
    return changes;
  }
  #parseReplicationEvent(content) {
    const str = content instanceof Buffer ? content.toString("utf-8") : new TextDecoder().decode(content);
    const json = JSON.parse(str);
    return parse(json, replicationEventSchema, "passthrough");
  }
  /**
   * If `ddlDetection === true`, relation messages are irrelevant,
   * as schema changes are detected by event triggers that
   * emit custom messages.
   *
   * For degraded-mode replication (`ddlDetection === false`):
   * 1. query the current published schemas on upstream
   * 2. compare that with the InternalShardConfig.initialSchema
   * 3. compare that with the incoming MessageRelation
   * 4. On any discrepancy, throw an UnsupportedSchemaChangeError
   *    to halt replication.
   *
   * Note that schemas queried in step [1] will be *post-transaction*
   * schemas, which are not necessarily suitable for actually processing
   * the statements in the transaction being replicated. In other words,
   * this mechanism cannot be used to reliably *replicate* schema changes.
   * However, they serve the purpose determining if schemas have changed.
   */
  async #handleRelation(rel) {
    const { publications, ddlDetection } = this.#shardConfig;
    if (ddlDetection) {
      return [];
    }
    const currentSchema = await getPublicationInfo(
      this.#upstreamDB,
      publications
    );
    const difference = getSchemaDifference(this.#initialSchema, currentSchema);
    if (difference !== null) {
      throw new MissingEventTriggerSupport(difference);
    }
    const orel = this.#initialSchema.tables.find(
      (t) => t.oid === rel.relationOid
    );
    if (!orel) {
      throw new MissingEventTriggerSupport(
        `relation not in initialSchema: ${stringify(rel)}`
      );
    }
    if (relationDifferent(orel, rel)) {
      throw new MissingEventTriggerSupport(
        `relation has changed within the transaction: ${stringify(orel)} vs ${stringify(rel)}`
      );
    }
    return [];
  }
}
function getSchemaDifference(a, b) {
  if (a.tables.length !== b.tables.length) {
    return `tables created or dropped`;
  }
  for (let i = 0; i < a.tables.length; i++) {
    const at = a.tables[i];
    const bt = b.tables[i];
    const difference = getTableDifference(at, bt);
    if (difference) {
      return difference;
    }
  }
  return null;
}
const byColumnPos = (a, b) => a[1].pos < b[1].pos ? -1 : a[1].pos > b[1].pos ? 1 : 0;
function getTableDifference(a, b) {
  if (a.oid !== b.oid || a.schema !== b.schema || a.name !== b.name) {
    return `Table "${a.name}" differs from table "${b.name}"`;
  }
  if (!deepEqual(a.primaryKey, b.primaryKey)) {
    return `Primary key of table "${a.name}" has changed`;
  }
  const acols = Object.entries(a.columns).sort(byColumnPos);
  const bcols = Object.entries(b.columns).sort(byColumnPos);
  if (acols.length !== bcols.length || acols.some(([aname, acol], i) => {
    const [bname, bcol] = bcols[i];
    return aname !== bname || acol.pos !== bcol.pos || acol.typeOID !== bcol.typeOID || acol.notNull !== bcol.notNull;
  })) {
    return `Columns of table "${a.name}" have changed`;
  }
  return null;
}
function relationDifferent(a, b) {
  if (a.oid !== b.relationOid || a.schema !== b.schema || a.name !== b.name) {
    return true;
  }
  if (
    // The MessageRelation's `keyColumns` field contains the columns in column
    // declaration order, whereas the PublishedTableSpec's `primaryKey`
    // contains the columns in primary key (i.e. index) order. Do an
    // order-agnostic compare here since it is not possible to detect
    // key-order changes from the MessageRelation message alone.
    b.replicaIdentity === "default" && !equals(new Set(a.primaryKey), new Set(b.keyColumns))
  ) {
    return true;
  }
  const acols = Object.entries(a.columns).sort(byColumnPos);
  const bcols = b.columns;
  return acols.length !== bcols.length || acols.some(([aname, acol], i) => {
    const bcol = bcols[i];
    return aname !== bcol.name || acol.typeOID !== bcol.typeOid;
  });
}
function translateError(e) {
  if (!(e instanceof Error)) {
    return new Error(String(e));
  }
  if (e instanceof postgres.PostgresError && e.code === PG_ADMIN_SHUTDOWN) {
    return new ShutdownSignal(e);
  }
  return e;
}
const idString = (id) => `${id.schema}.${id.name}`;
function specsByID(published) {
  return [
    // It would have been nice to use a CustomKeyMap here, but we rely on set-utils
    // operations which use plain Sets.
    new Map(published.tables.map((t) => [t.oid, t])),
    new Map(published.indexes.map((i) => [idString(i), i]))
  ];
}
function columnsByID(columns) {
  const colsByID = /* @__PURE__ */ new Map();
  for (const [name, spec] of Object.entries(columns)) {
    colsByID.set(spec.pos, { ...spec, name });
  }
  return colsByID;
}
function getMetadata(table) {
  return {
    schemaOID: must(table.schemaOID),
    relationOID: table.oid,
    rowKey: Object.fromEntries(
      table.replicaIdentityColumns.map((k) => [
        k,
        { attNum: table.columns[k].pos }
      ])
    )
  };
}
function makeRelation(relation) {
  const { columns: _, keyColumns, replicaIdentity, ...rest } = relation;
  return {
    ...rest,
    rowKey: {
      columns: keyColumns,
      type: replicaIdentity
    },
    // For now, deprecated columns are sent for backwards compatibility.
    // These can be removed when bumping the MIN_PROTOCOL_VERSION to 5.
    keyColumns,
    replicaIdentity
  };
}
class UnsupportedSchemaChangeError extends Error {
  name = "UnsupportedSchemaChangeError";
  description;
  ddlUpdate;
  constructor(description, ddlUpdate, options) {
    super(
      `Replication halted. Resync the replica to recover: ${description}`,
      options
    );
    this.description = description;
    this.ddlUpdate = ddlUpdate;
  }
}
class MissingEventTriggerSupport extends Error {
  name = "MissingEventTriggerSupport";
  constructor(msg) {
    super(
      `${msg}. Schema changes cannot be reliably replicated without event trigger support.`
    );
  }
}
class ShutdownSignal extends AbortError {
  name = "ShutdownSignal";
  constructor(cause) {
    super(
      "shutdown signal received (e.g. another zero-cache taking over the replication stream)",
      {
        cause
      }
    );
  }
}
export {
  Acker,
  initializePostgresChangeSource,
  relationDifferent
};
//# sourceMappingURL=change-source.js.map
