import {
  JobCollisionError,
  JobNotReplayableError,
  JobTemplateFieldMissingError,
  JobTypeNotFoundError,
  MissingTenantIdError
} from "./chunk-T4BIIU5E.js";
import {
  JOBS_LISTEN_NOTIFY,
  JOBS_MULTI_TENANT
} from "./chunk-ZPL74UQN.js";
import {
  jobRuns,
  jobSteps,
  jobs
} from "./chunk-OKXZ63IA.js";
import {
  JOBS_WAKE_CHANNEL,
  pgNotify
} from "./chunk-MYQIQ27N.js";
import {
  DRIZZLE
} from "./chunk-U64T4YZE.js";
import {
  __decorateClass,
  __decorateParam
} from "./chunk-2E224ZSN.js";

// runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts
import { randomUUID } from "crypto";
import { Inject, Injectable, Logger, Optional } from "@nestjs/common";
import { and, desc, eq, gt, inArray, isNotNull, ne, notInArray, sql } from "drizzle-orm";
var TERMINAL_STATUSES = [
  "completed",
  "failed",
  "timed_out",
  "canceled"
];
var DEDUPE_EXCLUDED_STATUSES = ["canceled", "failed"];
var IN_FLIGHT_STATUSES = ["pending", "running"];
function evaluateKeyTemplate(template, input) {
  return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, field) => {
    const value = input[field];
    if (value === void 0 || value === null) {
      throw new JobTemplateFieldMissingError(template, field);
    }
    return String(value);
  });
}
var DrizzleJobOrchestrator = class {
  constructor(db, multiTenant, listenNotify = false) {
    this.db = db;
    this.multiTenant = multiTenant;
    this.listenNotify = listenNotify;
  }
  db;
  multiTenant;
  listenNotify;
  // TODO(logging-subsystem): swap to ILogger once ADR-028 lands
  logger = new Logger(DrizzleJobOrchestrator.name);
  /**
   * JOB-8 — resolve `tenantId` for a mutating / targeted-read call.
   * Returns the tenant value that should be written to the row (or compared
   * against in a WHERE clause). When `multiTenant` is off, the column is
   * forced to `null` regardless of what callers pass. When on, `undefined`
   * throws; `null` and strings pass through untouched.
   */
  resolveTenantId(method, tenantId) {
    if (!this.multiTenant) return null;
    if (tenantId === void 0) throw new MissingTenantIdError(method);
    return tenantId;
  }
  // ==========================================================================
  // start
  // ==========================================================================
  async start(type, input, opts = {}, tx) {
    const payload = input ?? {};
    const tenantId = this.resolveTenantId("start", opts.tenantId);
    const client = tx ?? this.db;
    const [def] = await client.select().from(jobs).where(eq(jobs.type, type)).limit(1);
    if (!def) throw new JobTypeNotFoundError(type);
    const definition = def;
    if (definition.dedupeKeyTemplate && definition.dedupeWindowMs) {
      const dedupeKey2 = evaluateKeyTemplate(definition.dedupeKeyTemplate, payload);
      const windowStart = new Date(Date.now() - definition.dedupeWindowMs);
      const existing = await client.select().from(jobRuns).where(
        and(
          eq(jobRuns.jobType, type),
          eq(jobRuns.dedupeKey, dedupeKey2),
          gt(jobRuns.createdAt, windowStart),
          // status NOT IN ('canceled', 'failed')
          notInStatus(DEDUPE_EXCLUDED_STATUSES)
        )
      ).orderBy(desc(jobRuns.createdAt)).limit(1);
      if (existing.length > 0) {
        return existing[0];
      }
    }
    let concurrencyKey = null;
    if (definition.concurrencyKeyTemplate) {
      concurrencyKey = evaluateKeyTemplate(
        definition.concurrencyKeyTemplate,
        payload
      );
      const inFlight = await client.select().from(jobRuns).where(
        and(
          eq(jobRuns.concurrencyKey, concurrencyKey),
          inArray(jobRuns.status, IN_FLIGHT_STATUSES)
        )
      ).limit(1);
      if (inFlight.length > 0) {
        const incumbent = inFlight[0];
        switch (definition.collisionMode) {
          case "reject":
            throw new JobCollisionError(type, concurrencyKey, incumbent);
          case "replace":
            await this.cancel(incumbent.id, {
              cascade: true,
              reason: "replaced",
              tenantId: incumbent.tenantId
            });
            break;
          case "queue":
            break;
        }
      }
    }
    const newId = randomUUID();
    let rootRunId = newId;
    if (opts.parentRunId) {
      const [parent] = await client.select({ rootRunId: jobRuns.rootRunId }).from(jobRuns).where(eq(jobRuns.id, opts.parentRunId)).limit(1);
      if (!parent) {
        throw new Error(
          `parentRunId ${opts.parentRunId} does not reference an existing job_run`
        );
      }
      rootRunId = parent.rootRunId;
    }
    const dedupeKey = definition.dedupeKeyTemplate ? evaluateKeyTemplate(definition.dedupeKeyTemplate, payload) : null;
    const [inserted] = await client.insert(jobRuns).values({
      id: newId,
      jobType: type,
      jobVersion: definition.version,
      parentRunId: opts.parentRunId ?? null,
      rootRunId,
      parentClosePolicy: opts.parentClosePolicy ?? "terminate",
      scopeEntityType: opts.scope?.entityType ?? null,
      scopeEntityId: opts.scope?.entityId ?? null,
      tenantId,
      tags: opts.tags ?? {},
      pool: opts.pool ?? definition.pool,
      priority: opts.priority ?? definition.priorityDefault,
      concurrencyKey,
      dedupeKey,
      status: "pending",
      input: payload,
      output: null,
      error: null,
      triggerSource: opts.triggerSource ?? "manual",
      triggerRef: opts.triggerRef ?? null,
      runAt: opts.runAt ?? /* @__PURE__ */ new Date(),
      startedAt: null,
      finishedAt: null,
      claimedAt: null,
      attempts: 0
    }).returning();
    if (this.listenNotify) {
      const wakePool = inserted.pool;
      try {
        await pgNotify(client, JOBS_WAKE_CHANNEL, wakePool);
      } catch (err) {
        this.logger.warn(
          `pg_notify(${JOBS_WAKE_CHANNEL}, ${wakePool}) failed for run ${inserted.id}: ${err.message} (non-fatal \u2014 interval polling still claims the run).`
        );
      }
    }
    return inserted;
  }
  // ==========================================================================
  // cancel
  // ==========================================================================
  async cancel(runId, opts = {}) {
    const tenantId = this.resolveTenantId("cancel", opts.tenantId);
    const [target] = await this.db.select().from(jobRuns).where(eq(jobRuns.id, runId)).limit(1);
    if (!target) return;
    if (this.multiTenant && target.tenantId !== tenantId) return;
    if (TERMINAL_STATUSES.includes(target.status)) {
      return;
    }
    const [cancelled] = await this.db.update(jobRuns).set({
      status: "canceled",
      finishedAt: /* @__PURE__ */ new Date(),
      updatedAt: /* @__PURE__ */ new Date()
    }).where(
      and(eq(jobRuns.id, runId), notInStatus([...TERMINAL_STATUSES]))
    ).returning();
    if (!cancelled) return;
    if (opts.cascade === false) return;
    const descendants = await this.db.select().from(jobRuns).where(
      and(
        eq(jobRuns.rootRunId, target.rootRunId),
        ne(jobRuns.id, runId),
        notInStatus([...TERMINAL_STATUSES])
      )
    );
    for (const child of descendants) {
      const policy = child.parentClosePolicy;
      if (policy === "abandon") continue;
      await this.db.update(jobRuns).set({
        status: "canceled",
        finishedAt: /* @__PURE__ */ new Date(),
        updatedAt: /* @__PURE__ */ new Date()
      }).where(
        and(
          eq(jobRuns.id, child.id),
          notInStatus([...TERMINAL_STATUSES])
        )
      );
    }
    void opts.reason;
  }
  // ==========================================================================
  // replay
  // ==========================================================================
  async replay(runId) {
    const [target] = await this.db.select().from(jobRuns).where(eq(jobRuns.id, runId)).limit(1);
    if (!target) {
      throw new Error(`replay: run ${runId} not found`);
    }
    const run = target;
    if (!TERMINAL_STATUSES.includes(run.status)) {
      throw new JobNotReplayableError(runId, run.status);
    }
    const [def] = await this.db.select().from(jobs).where(eq(jobs.type, run.jobType)).limit(1);
    if (!def) throw new JobTypeNotFoundError(run.jobType);
    const mode = def.replayFrom;
    const result = await this.db.transaction(async (tx) => {
      if (mode === "scratch") {
        await tx.delete(jobSteps).where(eq(jobSteps.jobRunId, runId));
      } else if (mode === "last_step") {
        await tx.delete(jobSteps).where(
          and(eq(jobSteps.jobRunId, runId), ne(jobSteps.status, "completed"))
        );
      } else {
        await tx.delete(jobSteps).where(
          and(eq(jobSteps.jobRunId, runId), ne(jobSteps.status, "completed"))
        );
      }
      const [updated] = await tx.update(jobRuns).set({
        status: "pending",
        attempts: 0,
        runAt: /* @__PURE__ */ new Date(),
        startedAt: null,
        finishedAt: null,
        claimedAt: null,
        error: null,
        output: null,
        updatedAt: /* @__PURE__ */ new Date()
      }).where(eq(jobRuns.id, runId)).returning();
      return updated;
    });
    return result;
  }
  // ==========================================================================
  // upsertJobRows — boot-time materialisation of `job` definitions
  // ==========================================================================
  /**
   * Hash-gated `INSERT … ON CONFLICT (type) DO UPDATE … WHERE` per Q3
   * resolution (2026-04-19): the `UPDATE` branch executes only when one
   * of the persisted metadata fields differs from the incoming payload;
   * `version` bumps only on real change; concurrent boots with identical
   * content are idempotent no-ops.
   *
   * Why this shape (not `DO NOTHING`, not advisory locks):
   *   - `DO NOTHING` would let an old-version instance leave a stale row
   *     that a new-version instance can't overwrite during a rolling deploy.
   *   - Advisory locks add latency and leak risk under crashes.
   *   - The `WHERE … IS DISTINCT FROM …` clause makes the conditional
   *     atomic — no read-modify-write race on `version` between concurrent
   *     boots.
   *
   * Orphan detection: a single `SELECT type FROM job WHERE type NOT IN (...)`
   * returns the types present in DB but absent from `entries`. Caller (boot
   * validator) decides whether to throw `BootValidationError`.
   */
  async upsertJobRows(entries, poolConfig) {
    void poolConfig;
    for (const entry of entries) {
      const meta = entry.meta;
      const pool = meta.pool ?? "batch";
      const retryPolicy = meta.retry ?? {
        attempts: 1,
        backoff: "fixed",
        baseMs: 0
      };
      const concurrencyKeyTemplate = meta.concurrency?.key;
      const concurrencyKeyTemplateStr = typeof concurrencyKeyTemplate === "string" ? concurrencyKeyTemplate : null;
      const collisionMode = meta.concurrency?.collisionMode ?? "queue";
      const dedupeKeyTemplate = meta.dedupe?.key;
      const dedupeKeyTemplateStr = typeof dedupeKeyTemplate === "string" ? dedupeKeyTemplate : null;
      const dedupeWindowMs = meta.dedupe?.windowMs ?? null;
      const timeoutMs = meta.timeoutMs ?? null;
      const replayFrom = meta.replayFrom ?? "last_checkpoint";
      const scopeEntityType = meta.scope?.entity ?? null;
      const priorityDefault = 0;
      await this.db.insert(jobs).values({
        type: entry.type,
        version: 1,
        pool,
        scopeEntityType,
        retryPolicy,
        timeoutMs,
        concurrencyKeyTemplate: concurrencyKeyTemplateStr,
        collisionMode,
        dedupeKeyTemplate: dedupeKeyTemplateStr,
        dedupeWindowMs,
        priorityDefault,
        replayFrom
      }).onConflictDoUpdate({
        target: jobs.type,
        set: {
          pool: sql`EXCLUDED.pool`,
          scopeEntityType: sql`EXCLUDED.scope_entity_type`,
          retryPolicy: sql`EXCLUDED.retry_policy`,
          timeoutMs: sql`EXCLUDED.timeout_ms`,
          concurrencyKeyTemplate: sql`EXCLUDED.concurrency_key_template`,
          collisionMode: sql`EXCLUDED.collision_mode`,
          dedupeKeyTemplate: sql`EXCLUDED.dedupe_key_template`,
          dedupeWindowMs: sql`EXCLUDED.dedupe_window_ms`,
          priorityDefault: sql`EXCLUDED.priority_default`,
          replayFrom: sql`EXCLUDED.replay_from`,
          version: sql`${jobs.version} + 1`,
          updatedAt: sql`now()`
        },
        // The hash gate: every field listed in the Q3 resolution appears
        // here. `IS DISTINCT FROM` is the null-safe inequality operator;
        // jsonb cast to text gives stable comparison without invoking a
        // dedicated hash column (avoids a JOB-1 schema migration).
        setWhere: sql`
            ${jobs.pool} IS DISTINCT FROM EXCLUDED.pool OR
            ${jobs.retryPolicy}::text IS DISTINCT FROM EXCLUDED.retry_policy::text OR
            ${jobs.timeoutMs} IS DISTINCT FROM EXCLUDED.timeout_ms OR
            ${jobs.concurrencyKeyTemplate} IS DISTINCT FROM EXCLUDED.concurrency_key_template OR
            ${jobs.collisionMode} IS DISTINCT FROM EXCLUDED.collision_mode OR
            ${jobs.dedupeKeyTemplate} IS DISTINCT FROM EXCLUDED.dedupe_key_template OR
            ${jobs.dedupeWindowMs} IS DISTINCT FROM EXCLUDED.dedupe_window_ms OR
            ${jobs.priorityDefault} IS DISTINCT FROM EXCLUDED.priority_default OR
            ${jobs.replayFrom} IS DISTINCT FROM EXCLUDED.replay_from OR
            ${jobs.scopeEntityType} IS DISTINCT FROM EXCLUDED.scope_entity_type
          `
      });
    }
    const types = entries.map((e) => e.type);
    const orphans = types.length === 0 ? await this.db.select({ type: jobs.type }).from(jobs) : await this.db.select({ type: jobs.type }).from(jobs).where(notInArray(jobs.type, types));
    return { orphaned: orphans.map((o) => o.type) };
  }
};
DrizzleJobOrchestrator = __decorateClass([
  Injectable(),
  __decorateParam(0, Inject(DRIZZLE)),
  __decorateParam(1, Inject(JOBS_MULTI_TENANT)),
  __decorateParam(2, Optional()),
  __decorateParam(2, Inject(JOBS_LISTEN_NOTIFY))
], DrizzleJobOrchestrator);
function notInStatus(statuses) {
  const negated = statuses.map((s) => ne(jobRuns.status, s));
  return and(...negated);
}

export {
  TERMINAL_STATUSES,
  evaluateKeyTemplate,
  DrizzleJobOrchestrator
};
//# sourceMappingURL=chunk-OTR44OH6.js.map