import {
  EVENTS_WAKE_CHANNEL,
  PgNotifyListener,
  pgNotify
} from "./chunk-MYQIQ27N.js";
import {
  BRIDGE_OUTBOX_DRAIN_HOOK
} from "./chunk-4LH67P4U.js";
import {
  domainEvents
} from "./chunk-OFRRBC7M.js";
import {
  clampEventLimit,
  decodeEventCursor,
  encodeEventCursor
} from "./chunk-UQ5EHOH2.js";
import {
  EVENTS_MODULE_OPTIONS
} from "./chunk-H5NH7KPE.js";
import {
  DRIZZLE
} from "./chunk-U64T4YZE.js";
import {
  __decorateClass,
  __decorateParam
} from "./chunk-2E224ZSN.js";

// runtime/subsystems/events/event-bus.drizzle-backend.ts
import { Injectable, Inject, Logger, Optional } from "@nestjs/common";
import { eq, and, inArray, asc, desc, gte, lt, or, sql } from "drizzle-orm";
var POLL_INTERVAL_MS = 1e3;
var POLL_BATCH_SIZE = 50;
function toInsertValues(event, multiTenant) {
  const metadata = event.metadata ?? void 0;
  const pool = metadata?.["pool"] ?? null;
  const direction = metadata?.["direction"] ?? null;
  const tier = metadata?.["tier"] ?? "domain";
  const base = {
    id: event.id,
    type: event.type,
    aggregateId: event.aggregateId,
    aggregateType: event.aggregateType,
    payload: event.payload,
    occurredAt: event.occurredAt,
    processedAt: null,
    status: "pending",
    metadata: event.metadata,
    pool,
    direction,
    tier
  };
  if (!multiTenant) return base;
  const tenantId = metadata?.["tenantId"] ?? null;
  return { ...base, tenantId };
}
function toEventSummary(r) {
  const metadata = r.metadata ?? void 0;
  const rootRunId = metadata?.["rootRunId"];
  return {
    id: r.id,
    type: r.type,
    aggregateId: r.aggregateId,
    aggregateType: r.aggregateType,
    status: r.status,
    pool: r.pool,
    direction: r.direction,
    tier: r.tier,
    rootRunId: typeof rootRunId === "string" ? rootRunId : null,
    // EVT-8: `tenant_id` is a scaffold-time conditional column. Read it
    // structurally so this projection typechecks against both the
    // multi-tenant schema (column present) and the single-tenant schema
    // (column absent → undefined → null).
    tenantId: r.tenantId ?? null,
    occurredAt: r.occurredAt instanceof Date ? r.occurredAt : new Date(r.occurredAt),
    processedAt: r.processedAt == null ? null : r.processedAt instanceof Date ? r.processedAt : new Date(r.processedAt)
  };
}
var DrizzleEventBus = class {
  constructor(db, opts, bridgeHook = null) {
    this.db = db;
    this.bridgeHook = bridgeHook;
    this.opts = opts ?? { backend: "drizzle" };
  }
  db;
  bridgeHook;
  logger = new Logger(DrizzleEventBus.name);
  polling = false;
  pollTimer = null;
  handlers = /* @__PURE__ */ new Map();
  opts;
  // LISTEN-NOTIFY-1 — dedicated wake listener + debounce state. `null` when
  // `listenNotify` is off (the common case); polling is the only driver then.
  notifyListener = null;
  /** True while a wake-driven drain is in flight (debounce gate). */
  wakeDraining = false;
  /** A notify arrived mid-drain → re-drain once when the current drain ends. */
  wakeRecheckPending = false;
  // ============================================================================
  // Lifecycle
  // ============================================================================
  async onModuleInit() {
    this.polling = true;
    this.schedulePoll();
    if (this.opts.listenNotify) {
      const pool = this.db.$client;
      if (!pool || typeof pool.connect !== "function") {
        this.logger.warn(
          `listen_notify enabled but the Drizzle client exposes no pg Pool ($client.connect missing) \u2014 falling back to interval polling only.`
        );
      } else {
        this.notifyListener = new PgNotifyListener({
          channel: EVENTS_WAKE_CHANNEL,
          pool,
          label: "events",
          onNotify: (payload) => this.onWake(payload)
        });
        await this.notifyListener.start();
      }
    }
  }
  async onModuleDestroy() {
    this.polling = false;
    if (this.pollTimer) {
      clearTimeout(this.pollTimer);
      this.pollTimer = null;
    }
    if (this.notifyListener) {
      try {
        await this.notifyListener.stop();
      } catch (err) {
        this.logger.error(`notify listener stop failed: ${err}`);
      }
      this.notifyListener = null;
    }
  }
  /**
   * Wake handler — a `codegen_events_wake` notification arrived. A pool-filtered
   * drainer (`opts.pools` set) ignores payloads naming a pool it doesn't own; an
   * all-pools drainer wakes for any. Debounced: a notify mid-drain just flags a
   * re-check so a burst collapses to at most one extra drain (D3).
   */
  onWake(payload) {
    if (!this.polling) return;
    const pools = this.opts.pools;
    if (pools && pools.length > 0 && !pools.includes(payload)) return;
    if (this.wakeDraining) {
      this.wakeRecheckPending = true;
      return;
    }
    void this.drainOnWake();
  }
  async drainOnWake() {
    this.wakeDraining = true;
    try {
      do {
        this.wakeRecheckPending = false;
        await this.processBatch();
      } while (this.wakeRecheckPending && this.polling);
    } catch (err) {
      this.logger.error(`wake drain error: ${err}`);
    } finally {
      this.wakeDraining = false;
    }
  }
  // ============================================================================
  // IEventBus
  // ============================================================================
  async publish(event, tx) {
    const client = tx ?? this.db;
    const multiTenant = this.opts.multiTenant ?? false;
    const values = toInsertValues(event, multiTenant);
    await client.insert(domainEvents).values(values);
    await this.emitWakeNotify(client, [values.pool]);
  }
  async publishMany(events, tx) {
    if (events.length === 0) return;
    const client = tx ?? this.db;
    const multiTenant = this.opts.multiTenant ?? false;
    const valuesList = events.map((e) => toInsertValues(e, multiTenant));
    await client.insert(domainEvents).values(valuesList);
    await this.emitWakeNotify(client, valuesList.map((v) => v.pool));
  }
  /**
   * Emit one in-tx `pg_notify(codegen_events_wake, <pool>)` per distinct pool in
   * the just-inserted batch. No-op unless `listenNotify` is on. Best-effort: a
   * notify failure is non-fatal (interval polling still drains the rows), so we
   * log + swallow rather than failing the publish.
   */
  async emitWakeNotify(client, pools) {
    if (!this.opts.listenNotify) return;
    const distinct = new Set(pools.map((p) => p ?? ""));
    for (const pool of distinct) {
      try {
        await pgNotify(client, EVENTS_WAKE_CHANNEL, pool);
      } catch (err) {
        this.logger.warn(
          `pg_notify(${EVENTS_WAKE_CHANNEL}, '${pool}') failed: ${err} (non-fatal \u2014 interval polling still drains the outbox).`
        );
      }
    }
  }
  async findById(eventId) {
    const rows = await this.db.select().from(domainEvents).where(eq(domainEvents.id, eventId)).limit(1);
    const row = rows[0];
    if (!row) return null;
    return {
      id: row.id,
      type: row.type,
      aggregateId: row.aggregateId,
      aggregateType: row.aggregateType,
      payload: row.payload,
      occurredAt: row.occurredAt instanceof Date ? row.occurredAt : new Date(row.occurredAt),
      metadata: row.metadata ?? void 0
    };
  }
  subscribe(eventType, handler) {
    if (!this.handlers.has(eventType)) {
      this.handlers.set(eventType, /* @__PURE__ */ new Set());
    }
    const set = this.handlers.get(eventType);
    const h = handler;
    set.add(h);
    return () => {
      set.delete(h);
    };
  }
  // ============================================================================
  // IEventReadPort (OBS-LIST-1)
  // ============================================================================
  async listEvents(query = {}) {
    const limit = clampEventLimit(query.limit);
    const conditions = [];
    if (query.poolId) conditions.push(eq(domainEvents.pool, query.poolId));
    if (query.direction)
      conditions.push(eq(domainEvents.direction, query.direction));
    if (query.since) conditions.push(gte(domainEvents.occurredAt, query.since));
    if (query.rootRunId) {
      conditions.push(
        sql`${domainEvents.metadata}->>'rootRunId' = ${query.rootRunId}`
      );
    }
    if (this.opts.multiTenant && query.tenantId !== void 0) {
      const tenantIdColumn = domainEvents.tenantId;
      conditions.push(
        query.tenantId === null ? sql`${tenantIdColumn} is null` : eq(tenantIdColumn, query.tenantId)
      );
    }
    if (query.cursor) {
      const keyset = decodeEventCursor(query.cursor);
      if (keyset) {
        conditions.push(
          or(
            lt(domainEvents.occurredAt, keyset.occurredAt),
            and(
              eq(domainEvents.occurredAt, keyset.occurredAt),
              lt(domainEvents.id, keyset.id)
            )
          )
        );
      }
    }
    const rows = await this.db.select().from(domainEvents).where(conditions.length > 0 ? and(...conditions) : void 0).orderBy(desc(domainEvents.occurredAt), desc(domainEvents.id)).limit(limit + 1);
    const hasMore = rows.length > limit;
    const page = hasMore ? rows.slice(0, limit) : rows;
    const items = page.map(toEventSummary);
    const last = page[page.length - 1];
    const nextCursor = hasMore && last ? encodeEventCursor({ occurredAt: last.occurredAt, id: last.id }) : null;
    return { items, nextCursor };
  }
  // ============================================================================
  // Polling
  // ============================================================================
  /**
   * Test-only hook. Runs exactly one drain cycle and returns. Production
   * code goes through `onModuleInit` → `schedulePoll`, which calls the
   * same `processBatch` under a timer.
   */
  async drainOnce() {
    await this.processBatch();
  }
  schedulePoll() {
    if (!this.polling) return;
    this.pollTimer = setTimeout(async () => {
      try {
        await this.processBatch();
      } catch (err) {
        this.logger.error(`Poll cycle error: ${err}`);
      } finally {
        this.schedulePoll();
      }
    }, POLL_INTERVAL_MS);
  }
  /**
   * Drain one batch (BRIDGE-4 restructure of EVT-4).
   *
   * Two-phase per drained event:
   *
   *   1. **Per-event transaction** — bridge fanout (`bridgeHook.processEvent`)
   *      + `processed_at` stamp. Both write through the same `tx`. A throw
   *      inside the tx (only infra-level failures should reach here, since
   *      the hook tolerates null direction and registry misses inline)
   *      rolls back the bridge inserts AND the `processed_at` stamp; the
   *      event re-claims on the next drain cycle. Bridge `UNIQUE
   *      (event_id, trigger_id)` makes the retry idempotent.
   *
   *   2. **After commit** — dispatch in-process subscribers (`IEventBus.subscribe`
   *      handlers). This deliberately runs OUTSIDE the per-event tx (lead
   *      decision 2026-04-22): subscribers are best-effort and must not
   *      gate forward progress or roll back bridge fanout. Subscriber
   *      errors are caught + logged; `processed_at` is already committed.
   *      The old `MAX_RETRIES=3` in-process retry loop and the
   *      `failed`-stamping path were removed in BRIDGE-4 along with their
   *      coupling.
   *
   * The `processed_at` UPDATE carries `AND status='pending'` (BRIDGE-4
   * tightening — without it, a hypothetical double-claim could double-stamp
   * the timestamp). The per-event tx + `FOR UPDATE SKIP LOCKED` claim
   * make this defensive belt-and-suspenders.
   */
  async processBatch() {
    const pools = this.opts.pools;
    const whereClause = pools && pools.length > 0 ? and(eq(domainEvents.status, "pending"), inArray(domainEvents.pool, pools)) : eq(domainEvents.status, "pending");
    const rows = await this.db.transaction(async (tx) => {
      return tx.select().from(domainEvents).where(whereClause).orderBy(asc(domainEvents.occurredAt)).limit(POLL_BATCH_SIZE).for("update", { skipLocked: true });
    });
    for (const row of rows) {
      const event = {
        id: row.id,
        type: row.type,
        aggregateId: row.aggregateId,
        aggregateType: row.aggregateType,
        payload: row.payload,
        occurredAt: row.occurredAt instanceof Date ? row.occurredAt : new Date(row.occurredAt),
        metadata: row.metadata ?? void 0
      };
      try {
        await this.db.transaction(async (tx) => {
          if (this.bridgeHook) {
            await this.bridgeHook.processEvent(event, tx);
          }
          await tx.update(domainEvents).set({ status: "processed", processedAt: /* @__PURE__ */ new Date() }).where(
            and(
              eq(domainEvents.id, event.id),
              eq(domainEvents.status, "pending")
            )
          );
        });
      } catch (err) {
        this.logger.error(
          `Per-event tx failed for event id=${event.id} type=${event.type}: ${err}`
        );
        continue;
      }
      try {
        await this.dispatch(event);
      } catch (err) {
        this.logger.error(
          `Subscriber dispatch failed for event id=${event.id} type=${event.type} (processed_at already committed; failure does not retry): ${err}`
        );
      }
    }
  }
  async dispatch(event) {
    const set = this.handlers.get(event.type);
    if (!set) return;
    let firstError;
    for (const handler of set) {
      try {
        await handler(event);
      } catch (err) {
        this.logger.error(
          `Handler error for event type "${event.type}" (id: ${event.id}): ${err}`
        );
        if (firstError === void 0) {
          firstError = err;
        }
      }
    }
    if (firstError !== void 0) {
      throw firstError;
    }
  }
};
DrizzleEventBus = __decorateClass([
  Injectable(),
  __decorateParam(0, Inject(DRIZZLE)),
  __decorateParam(1, Optional()),
  __decorateParam(1, Inject(EVENTS_MODULE_OPTIONS)),
  __decorateParam(2, Optional()),
  __decorateParam(2, Inject(BRIDGE_OUTBOX_DRAIN_HOOK))
], DrizzleEventBus);

export {
  DrizzleEventBus
};
//# sourceMappingURL=chunk-UTNWFHJF.js.map