UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

1,682 lines 66 kB
/**
 * @beignet/core/outbox
 *
 * Durable outbox primitives for transactionally recording events and jobs that
 * should be delivered after the owning database transaction commits.
 */
import { prepareEventPayloadForTransport, } from "../events/index.js";
import { getJobRetryDelayMs, getJobRetryMaxAttempts, parseJobPayload, SINGLE_ATTEMPT_DISPATCH, shouldRetryJob, } from "../jobs/index.js";
import { createProviderInstrumentation, } from "../providers/index.js";
import { captureTraceCarrier, parseTraceCarrier, resolveTracingPort, runWithTracing, } from "../tracing/index.js";
/**
 * Default lease duration for claimed outbox messages.
 */
export const DEFAULT_OUTBOX_LEASE_MS = 30_000;
/**
 * Default maximum messages handled by one bounded drain pass.
 */
export const DEFAULT_OUTBOX_BATCH_SIZE = 100;
/**
 * Default number of outbox messages delivered concurrently.
 */
export const DEFAULT_OUTBOX_CONCURRENCY = 1;
/**
 * Default maximum time Beignet renews a claim for one delivery.
 */
export const DEFAULT_OUTBOX_MAX_ACTIVE_MS = 300_000;
/**
 * Default maximum delivery attempts before a message is dead-lettered.
 */
export const DEFAULT_OUTBOX_MAX_ATTEMPTS = 3;
/**
 * Default maximum messages returned from outbox admin list calls.
 */
export const DEFAULT_OUTBOX_ADMIN_LIST_LIMIT = 50;
/**
 * Error thrown when an outbox payload is not JSON serializable.
 */
export class OutboxSerializationError extends Error {
    constructor(message) {
        super(message);
        this.name = "OutboxSerializationError";
    }
}
/**
 * Error thrown when an outbox message cannot be resolved through the registry.
 */
export class OutboxRegistryError extends Error {
    constructor(message) {
        super(message);
        this.name = "OutboxRegistryError";
    }
}
/**
 * Error thrown when a claimed message cannot be updated with the supplied token.
 */
export class OutboxClaimError extends Error {
    /**
     * Message ID involved in the claim error.
     */
    id;
    constructor(args) {
        super(args.message);
        this.name = "OutboxClaimError";
        this.id = args.id;
    }
}
/**
 * Error persisted when an eligible message has exhausted its claim attempts
 * without reaching a terminal settlement.
 */
export class OutboxAbandonedClaimError extends Error {
    /** Message ID whose attempt budget was exhausted. */
    id;
    /** Number of claims already made. */
    attempts;
    /** Maximum permitted claim attempts. */
    maxAttempts;
    constructor(args) {
        super(`Outbox message "${args.id}" exhausted ${args.maxAttempts} claim attempts without a terminal settlement.`);
        this.name = "OutboxAbandonedClaimError";
        this.id = args.id;
        this.attempts = args.attempts;
        this.maxAttempts = args.maxAttempts;
    }
}
/** Error surfaced when an outbox worker no longer has a confirmed claim. */
export class OutboxLeaseLostError extends Error {
    /** Message ID whose ownership was lost. */
    id;
    constructor(args) {
        super(args.message ??
            `Outbox claim for message "${args.id}" is no longer active.`, args.cause === undefined ? undefined : { cause: args.cause });
        this.name = "OutboxLeaseLostError";
        this.id = args.id;
    }
}
/** Error surfaced when delivery exceeds the configured active-claim window. */
export class OutboxMaxActiveDurationError extends Error {
    /** Message ID whose active window elapsed. */
    id;
    /** Configured maximum active duration. */
    maxActiveMs;
    constructor(args) {
        super(`Outbox delivery for message "${args.id}" exceeded the ${args.maxActiveMs}ms active-claim limit.`);
        this.name = "OutboxMaxActiveDurationError";
        this.id = args.id;
        this.maxActiveMs = args.maxActiveMs;
    }
}
/**
 * Error thrown when an outbox admin operation cannot be completed safely.
 */
export class OutboxAdminError extends Error {
    /**
     * Message ID involved in the admin error, when applicable.
     */
    id;
    constructor(args) {
        super(args.message);
        this.name = "OutboxAdminError";
        this.id = args.id;
    }
}
function assertNonEmptyString(name, value) {
    if (typeof value !== "string" || value.trim().length === 0) {
        throw new Error(`${name} must be a non-empty string`);
    }
}
function assertPositiveInteger(name, value) {
    if (!Number.isInteger(value) || value <= 0) {
        throw new Error(`${name} must be a positive integer`);
    }
}
function assertValidDate(name, value) {
    if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
        throw new Error(`${name} must be a valid Date`);
    }
}
function cloneDate(value) {
    return new Date(value.getTime());
}
function createId() {
    if (!globalThis.crypto?.randomUUID) {
        throw new Error("crypto.randomUUID is required to create outbox IDs.");
    }
    return globalThis.crypto.randomUUID();
}
function assertJsonValue(value, path = [], seen = new WeakSet()) {
    const label = path.length > 0 ? path.join(".") : "payload";
    if (value === null)
        return null;
    if (typeof value === "string" ||
        typeof value === "boolean" ||
        typeof value === "number") {
        if (typeof value === "number" && !Number.isFinite(value)) {
            throw new OutboxSerializationError(`Outbox ${label} must be a finite number.`);
        }
        return value;
    }
    if (Array.isArray(value)) {
        if (seen.has(value)) {
            throw new OutboxSerializationError(`Outbox ${label} must be JSON serializable. Circular references are not supported.`);
        }
        seen.add(value);
        try {
            return value.map((item, index) => assertJsonValue(item, [...path, String(index)], seen));
        }
        finally {
            seen.delete(value);
        }
    }
    if (typeof value === "object") {
        if (value instanceof Date) {
            throw new OutboxSerializationError(`Outbox ${label} must be JSON serializable. Convert Date values to strings before enqueueing.`);
        }
        if (seen.has(value)) {
            throw new OutboxSerializationError(`Outbox ${label} must be JSON serializable. Circular references are not supported.`);
        }
        seen.add(value);
        try {
            const record = value;
            const output = {};
            for (const key of Object.keys(record)) {
                const child = record[key];
                if (child === undefined) {
                    throw new OutboxSerializationError(`Outbox ${[...path, key].join(".")} cannot be undefined.`);
                }
                output[key] = assertJsonValue(child, [...path, key], seen);
            }
            return output;
        }
        finally {
            seen.delete(value);
        }
    }
    throw new OutboxSerializationError(`Outbox ${label} must be JSON serializable. Received ${typeof value}.`);
}
/**
 * Convert an unknown value to an outbox-safe JSON value.
 *
 * Dates, undefined values, functions, non-finite numbers, symbols, and circular
 * references are rejected so durable adapters can store the payload safely.
 */
export function toOutboxJsonValue(value) {
    const jsonValue = assertJsonValue(value);
    return JSON.parse(JSON.stringify(jsonValue));
}
/**
 * Serialize an unknown delivery error into outbox error metadata.
 */
export function serializeOutboxError(error) {
    if (error instanceof Error) {
        return {
            name: error.name,
            message: error.message,
            stack: error.stack,
        };
    }
    if (typeof error === "string") {
        return { message: error };
    }
    return { message: "Unknown outbox delivery error" };
}
function copyMessage(message) {
    return {
        ...message,
        availableAt: cloneDate(message.availableAt),
        claimedAt: message.claimedAt ? cloneDate(message.claimedAt) : null,
        lockedUntil: message.lockedUntil ? cloneDate(message.lockedUntil) : null,
        deliveredAt: message.deliveredAt ? cloneDate(message.deliveredAt) : null,
        createdAt: cloneDate(message.createdAt),
        updatedAt: cloneDate(message.updatedAt),
        lastError: message.lastError ? { ...message.lastError } : null,
    };
}
function normalizeStatusFilter(status) {
    if (status === undefined)
        return undefined;
    return new Set(Array.isArray(status) ? status : [status]);
}
function messageMatchesQuery(message, query = {}) {
    const statuses = normalizeStatusFilter(query.status);
    if (statuses && !statuses.has(message.status))
        return false;
    if (query.kind !== undefined && message.kind !== query.kind)
        return false;
    if (query.name !== undefined && message.name !== query.name)
        return false;
    if (query.updatedBefore !== undefined &&
        message.updatedAt.getTime() >= query.updatedBefore.getTime()) {
        return false;
    }
    if (query.deliveredBefore !== undefined &&
        (message.deliveredAt === null ||
            message.deliveredAt.getTime() >= query.deliveredBefore.getTime())) {
        return false;
    }
    return true;
}
function compareMessagesForAdminList(left, right) {
    const updated = right.updatedAt.getTime() - left.updatedAt.getTime();
    if (updated !== 0)
        return updated;
    const created = right.createdAt.getTime() - left.createdAt.getTime();
    if (created !== 0)
        return created;
    return right.id.localeCompare(left.id);
}
function compareMessagesForAdminCleanup(left, right) {
    const updated = left.updatedAt.getTime() - right.updatedAt.getTime();
    if (updated !== 0)
        return updated;
    const created = left.createdAt.getTime() - right.createdAt.getTime();
    if (created !== 0)
        return created;
    return left.id.localeCompare(right.id);
}
function compareDeliveredMessagesForPrune(left, right) {
    const delivered = (left.deliveredAt?.getTime() ?? 0) - (right.deliveredAt?.getTime() ?? 0);
    if (delivered !== 0)
        return delivered;
    return compareMessagesForAdminCleanup(left, right);
}
function resolveAdminListLimit(limit) {
    const resolved = limit ?? DEFAULT_OUTBOX_ADMIN_LIST_LIMIT;
    assertPositiveInteger("limit", resolved);
    return resolved;
}
function toClaimedMessage(message) {
    if (message.status !== "claimed" ||
        !message.claimToken ||
        !message.claimedAt ||
        !message.lockedUntil) {
        throw new OutboxClaimError({
            id: message.id,
            message: `Outbox message "${message.id}" is not claimed.`,
        });
    }
    return {
        ...copyMessage(message),
        status: "claimed",
        claimToken: message.claimToken,
        claimedAt: cloneDate(message.claimedAt),
        lockedUntil: cloneDate(message.lockedUntil),
    };
}
/**
 * Create a validated pending outbox message.
 */
export function createOutboxMessage(input, options = {}) {
    assertNonEmptyString("kind", input.kind);
    assertNonEmptyString("name", input.name);
    if (input.id !== undefined)
        assertNonEmptyString("id", input.id);
    if (input.maxAttempts !== undefined) {
        assertPositiveInteger("maxAttempts", input.maxAttempts);
    }
    const now = options.now ?? new Date();
    const trace = parseTraceCarrier(input.trace);
    return {
        id: options.id ?? input.id ?? options.createId?.() ?? createId(),
        kind: input.kind,
        name: input.name,
        payload: toOutboxJsonValue(input.payload),
        ...(trace ? { trace } : {}),
        status: "pending",
        attempts: 0,
        maxAttempts: input.maxAttempts ?? DEFAULT_OUTBOX_MAX_ATTEMPTS,
        availableAt: input.availableAt
            ? cloneDate(input.availableAt)
            : cloneDate(now),
        claimedAt: null,
        lockedUntil: null,
        claimToken: null,
        deliveredAt: null,
        lastError: null,
        createdAt: cloneDate(now),
        updatedAt: cloneDate(now),
    };
}
function isEligible(message, now) {
    if (message.status === "pending") {
        return message.availableAt.getTime() <= now.getTime();
    }
    return (message.status === "claimed" &&
        message.lockedUntil !== null &&
        message.lockedUntil.getTime() <= now.getTime());
}
/**
 * Create an in-memory outbox for tests and local examples.
 *
 * The memory outbox is process-local and not durable.
 */
export function createMemoryOutbox(storeOptions = {}) {
    const createStoreId = storeOptions.id ?? createId;
    const storeNow = storeOptions.now ?? (() => new Date());
    const messages = new Map();
    function getClaimedOrThrow(id, claimToken) {
        const message = messages.get(id);
        if (!message) {
            throw new OutboxClaimError({
                id,
                message: `Outbox message "${id}" does not exist.`,
            });
        }
        if (message.status !== "claimed" || message.claimToken !== claimToken) {
            throw new OutboxClaimError({
                id,
                message: `Outbox message "${id}" is not claimed by this worker.`,
            });
        }
        return message;
    }
    return {
        get messages() {
            return [...messages.values()].map(copyMessage);
        },
        async listMessages(options = {}) {
            const limit = resolveAdminListLimit(options.limit);
            if (options.updatedBefore) {
                assertValidDate("updatedBefore", options.updatedBefore);
            }
            if (options.deliveredBefore) {
                assertValidDate("deliveredBefore", options.deliveredBefore);
            }
            return [...messages.values()]
                .filter((message) => messageMatchesQuery(message, options))
                .sort(compareMessagesForAdminList)
                .slice(0, limit)
                .map(copyMessage);
        },
        async countMessages(options = {}) {
            if (options.updatedBefore) {
                assertValidDate("updatedBefore", options.updatedBefore);
            }
            if (options.deliveredBefore) {
                assertValidDate("deliveredBefore", options.deliveredBefore);
            }
            return [...messages.values()].filter((message) => messageMatchesQuery(message, options)).length;
        },
        async getMessage(id) {
            assertNonEmptyString("id", id);
            const message = messages.get(id);
            return message ? copyMessage(message) : null;
        },
        async requeueMessage(input) {
            assertNonEmptyString("id", input.id);
            const message = messages.get(input.id);
            if (!message) {
                throw new OutboxAdminError({
                    id: input.id,
                    message: `Outbox message "${input.id}" does not exist.`,
                });
            }
            if (message.status !== "deadLettered") {
                throw new OutboxAdminError({
                    id: input.id,
                    message: `Outbox message "${input.id}" is not dead-lettered.`,
                });
            }
            const now = input.now ?? storeNow();
            const availableAt = input.availableAt ?? now;
            assertValidDate("now", now);
            assertValidDate("availableAt", availableAt);
            message.status = "pending";
            message.availableAt = cloneDate(availableAt);
            message.claimToken = null;
            message.claimedAt = null;
            message.lockedUntil = null;
            if (input.resetAttempts)
                message.attempts = 0;
            message.updatedAt = cloneDate(now);
            return copyMessage(message);
        },
        async purgeDeadLettered(input = {}) {
            const limit = input.limit === undefined
                ? undefined
                : resolveAdminListLimit(input.limit);
            if (input.before)
                assertValidDate("before", input.before);
            const candidates = [...messages.values()]
                .filter((message) => message.status === "deadLettered" &&
                (input.before === undefined ||
                    message.updatedAt.getTime() < input.before.getTime()))
                .sort(compareMessagesForAdminCleanup)
                .slice(0, limit);
            for (const message of candidates) {
                messages.delete(message.id);
            }
            return { deleted: candidates.length };
        },
        async pruneDelivered(input) {
            assertValidDate("before", input.before);
            const limit = input.limit === undefined
                ? undefined
                : resolveAdminListLimit(input.limit);
            const candidates = [...messages.values()]
                .filter((message) => message.status === "delivered" &&
                message.deliveredAt !== null &&
                message.deliveredAt.getTime() < input.before.getTime())
                .sort(compareDeliveredMessagesForPrune)
                .slice(0, limit);
            for (const message of candidates) {
                messages.delete(message.id);
            }
            return { deleted: candidates.length };
        },
        async enqueue(input) {
            const message = createOutboxMessage(input, {
                createId: createStoreId,
                now: storeNow(),
            });
            if (messages.has(message.id)) {
                throw new Error(`Outbox message "${message.id}" already exists.`);
            }
            messages.set(message.id, message);
            return copyMessage(message);
        },
        async claimBatch(options) {
            assertPositiveInteger("limit", options.limit);
            const now = options.now ?? storeNow();
            const leaseMs = options.leaseMs ?? DEFAULT_OUTBOX_LEASE_MS;
            assertValidDate("now", now);
            assertPositiveInteger("leaseMs", leaseMs);
            const lockedUntil = new Date(now.getTime() + leaseMs);
            const claimed = [];
            const deadLettered = [];
            const eligible = [...messages.values()]
                .filter((message) => isEligible(message, now))
                .sort((a, b) => {
                const available = a.availableAt.getTime() - b.availableAt.getTime();
                if (available !== 0)
                    return available;
                return a.createdAt.getTime() - b.createdAt.getTime();
            })
                .slice(0, options.limit);
            for (const message of eligible) {
                if (message.attempts >= message.maxAttempts) {
                    message.status = "deadLettered";
                    message.lastError = serializeOutboxError(new OutboxAbandonedClaimError({
                        id: message.id,
                        attempts: message.attempts,
                        maxAttempts: message.maxAttempts,
                    }));
                    message.claimToken = null;
                    message.claimedAt = null;
                    message.lockedUntil = null;
                    message.updatedAt = cloneDate(now);
                    deadLettered.push(copyMessage(message));
                    continue;
                }
                message.status = "claimed";
                message.attempts += 1;
                message.claimToken = createStoreId();
                message.claimedAt = cloneDate(now);
                message.lockedUntil = cloneDate(lockedUntil);
                message.updatedAt = cloneDate(now);
                claimed.push(toClaimedMessage(message));
            }
            return { claimed, deadLettered };
        },
        async renewClaim(input) {
            assertNonEmptyString("id", input.id);
            assertNonEmptyString("claimToken", input.claimToken);
            const message = getClaimedOrThrow(input.id, input.claimToken);
            const now = input.now ?? storeNow();
            const leaseMs = input.leaseMs ?? DEFAULT_OUTBOX_LEASE_MS;
            assertValidDate("now", now);
            assertPositiveInteger("leaseMs", leaseMs);
            if (message.lockedUntil === null ||
                message.lockedUntil.getTime() <= now.getTime()) {
                throw new OutboxClaimError({
                    id: input.id,
                    message: `Outbox message "${input.id}" no longer has an active claim to renew.`,
                });
            }
            const lockedUntil = new Date(now.getTime() + leaseMs);
            message.lockedUntil = cloneDate(lockedUntil);
            message.updatedAt = cloneDate(now);
            return { lockedUntil: cloneDate(lockedUntil) };
        },
        async markDelivered(input) {
            assertNonEmptyString("id", input.id);
            assertNonEmptyString("claimToken", input.claimToken);
            const message = getClaimedOrThrow(input.id, input.claimToken);
            const now = input.now ?? storeNow();
            assertValidDate("now", now);
            if (message.lockedUntil === null ||
                message.lockedUntil.getTime() <= now.getTime()) {
                throw new OutboxClaimError({
                    id: input.id,
                    message: `Outbox message "${input.id}" no longer has an active claim to settle.`,
                });
            }
            message.status = "delivered";
            message.deliveredAt = cloneDate(now);
            message.claimToken = null;
            message.claimedAt = null;
            message.lockedUntil = null;
            message.updatedAt = cloneDate(now);
        },
        async markFailed(input) {
            assertNonEmptyString("id", input.id);
            assertNonEmptyString("claimToken", input.claimToken);
            const message = getClaimedOrThrow(input.id, input.claimToken);
            const now = input.now ?? storeNow();
            assertValidDate("now", now);
            if (input.retryAt)
                assertValidDate("retryAt", input.retryAt);
            if (message.lockedUntil === null ||
                message.lockedUntil.getTime() <= now.getTime()) {
                throw new OutboxClaimError({
                    id: input.id,
                    message: `Outbox message "${input.id}" no longer has an active claim to settle.`,
                });
            }
            message.status = input.deadLetter ? "deadLettered" : "pending";
            message.lastError = serializeOutboxError(input.error);
            message.availableAt = input.retryAt
                ? cloneDate(input.retryAt)
                : cloneDate(now);
            message.claimToken = null;
            message.claimedAt = null;
            message.lockedUntil = null;
            message.updatedAt = cloneDate(now);
        },
        clear() {
            messages.clear();
        },
    };
}
function mapDefinitions(kind, defs) {
    const map = new Map();
    for (const def of defs) {
        if (map.has(def.name)) {
            throw new OutboxRegistryError(`Duplicate ${kind} definition "${def.name}" in outbox registry.`);
        }
        map.set(def.name, def);
    }
    return map;
}
/**
 * Define the events and jobs that an outbox drain worker can deliver.
 *
 * Duplicate names throw because message delivery resolves by name.
 */
export function defineOutboxRegistry(input) {
    return {
        events: mapDefinitions("event", input.events ?? []),
        jobs: mapDefinitions("job", input.jobs ?? []),
    };
}
/**
 * Validate an event payload and enqueue it as an outbox message.
 */
export async function enqueueEvent(outbox, event, payload, options = {}) {
    const prepared = await prepareEventPayloadForTransport(event, payload);
    return await enqueueTransportEvent(outbox, event, prepared.transportValue, options);
}
async function enqueueTransportEvent(outbox, event, payload, options) {
    const trace = parseTraceCarrier(options.trace) ?? captureTraceCarrier(options.tracing);
    return outbox.enqueue({
        id: options.id,
        kind: "event",
        name: event.name,
        payload,
        trace,
        availableAt: options.availableAt,
        maxAttempts: options.maxAttempts,
    });
}
/**
 * Validate a job payload and enqueue it as an outbox message.
 */
export async function enqueueJob(outbox, job, payload, options = {}) {
    await parseJobPayload(job, payload);
    const trace = parseTraceCarrier(options.trace) ?? captureTraceCarrier(options.tracing);
    return outbox.enqueue({
        id: options.id,
        kind: "job",
        name: job.name,
        payload: toOutboxJsonValue(payload),
        trace,
        availableAt: options.availableAt,
        maxAttempts: options.maxAttempts ?? getJobRetryMaxAttempts(job.retry),
    });
}
/**
 * Create a domain event recorder that writes events to the outbox.
 */
export function createOutboxEventRecorder(outbox, options = {}) {
    return {
        async record(event, payload, publishOptions) {
            const prepared = await prepareEventPayloadForTransport(event, payload, publishOptions);
            await enqueueTransportEvent(outbox, event, prepared.transportValue, {
                ...options,
                trace: prepared.publishOptions.trace ?? options.trace,
            });
        },
    };
}
/**
 * Create a job dispatcher that writes jobs to the outbox.
 */
export function createOutboxJobDispatcher(outbox, options = {}) {
    return {
        async dispatch(job, payload, dispatchOptions) {
            await enqueueJob(outbox, job, payload, {
                ...options,
                trace: dispatchOptions?.trace ?? options.trace,
            });
        },
    };
}
function resolveRetryDelayMs(options, message, error, now) {
    if (typeof options.retryDelayMs === "function") {
        const delay = options.retryDelayMs({ message, error, now });
        assertPositiveInteger("retryDelayMs", delay);
        return delay;
    }
    if (options.retryDelayMs !== undefined) {
        assertPositiveInteger("retryDelayMs", options.retryDelayMs);
        return options.retryDelayMs;
    }
    if (message.kind === "job") {
        const job = options.registry.jobs.get(message.name);
        if (job?.retry) {
            return getJobRetryDelayMs(job.retry, {
                attempt: message.attempts,
                error,
                jobName: message.name,
            });
        }
    }
    return Math.min(60_000, 1000 * 2 ** Math.max(0, message.attempts - 1));
}
function shouldRetryOutboxMessage(options, message, error) {
    if (message.kind !== "job") {
        return message.attempts < message.maxAttempts;
    }
    const job = options.registry.jobs.get(message.name);
    return shouldRetryJob(job?.retry, {
        attempt: message.attempts,
        error,
        jobName: message.name,
        maxAttempts: message.maxAttempts,
    });
}
function outboxInstrumentationDetails(message, details) {
    return {
        attempt: message.attempts,
        maxAttempts: message.maxAttempts,
        messageId: message.id,
        messageKind: message.kind,
        messageName: message.name,
        ...details,
    };
}
async function deliverOutboxMessage(options, message, trace) {
    if (message.kind === "event") {
        if (!options.eventBus) {
            throw new OutboxRegistryError(`Cannot deliver event "${message.name}" without an event bus.`);
        }
        const event = options.registry.events.get(message.name);
        if (!event) {
            throw new OutboxRegistryError(`Outbox registry does not include event "${message.name}".`);
        }
        const prepared = await prepareEventPayloadForTransport(event, message.payload, trace ? { trace } : undefined);
        await options.eventBus.publish(event, prepared.payload, prepared.publishOptions);
        return;
    }
    if (!options.jobs) {
        throw new OutboxRegistryError(`Cannot deliver job "${message.name}" without a job dispatcher.`);
    }
    const job = options.registry.jobs.get(message.name);
    if (!job) {
        throw new OutboxRegistryError(`Outbox registry does not include job "${message.name}".`);
    }
    await parseJobPayload(job, message.payload);
    // The drain owns execution retries: failed deliveries are rescheduled with
    // the job's own policy via markFailed/retryAt. When the dispatcher exposes
    // a single-attempt dispatch (the inline dispatcher does), use it so the
    // retry policy runs in exactly one layer. Durable providers do not expose
    // it — for them dispatch is an enqueue and the queue owns execution.
    const singleAttempt = options.jobs[SINGLE_ATTEMPT_DISPATCH];
    if (singleAttempt) {
        await singleAttempt(job, message.payload, {
            attempt: message.attempts,
            maxAttempts: message.maxAttempts,
            trace,
        });
        return;
    }
    await options.jobs.dispatch(job, message.payload, trace ? { trace } : undefined);
}
const MAX_TIMER_DELAY_MS = 2_147_483_647;
const OUTBOX_SETTLEMENT_ATTEMPTS = 3;
const OUTBOX_SETTLEMENT_RETRY_DELAY_MS = 100;
function defaultOutboxWait(delayMs, signal) {
    return new Promise((resolve) => {
        if (signal.aborted) {
            resolve();
            return;
        }
        let timer;
        const finish = () => {
            if (timer !== undefined)
                clearTimeout(timer);
            signal.removeEventListener("abort", finish);
            resolve();
        };
        timer = setTimeout(finish, delayMs);
        signal.addEventListener("abort", finish, { once: true });
    });
}
function readOutboxNow(now) {
    const value = now();
    assertValidDate("now", value);
    return value;
}
function assertTimerDuration(name, value) {
    assertPositiveInteger(name, value);
    if (value > MAX_TIMER_DELAY_MS) {
        throw new Error(`${name} must be at most ${MAX_TIMER_DELAY_MS}`);
    }
}
function resolveDrainRuntime(options) {
    const batchSize = options.batchSize ?? DEFAULT_OUTBOX_BATCH_SIZE;
    const concurrency = options.concurrency ?? DEFAULT_OUTBOX_CONCURRENCY;
    const leaseMs = options.leaseMs ?? DEFAULT_OUTBOX_LEASE_MS;
    const heartbeatMs = options.heartbeatMs ?? Math.max(1, Math.floor(leaseMs / 3));
    const maxActiveMs = options.maxActiveMs ?? DEFAULT_OUTBOX_MAX_ACTIVE_MS;
    assertPositiveInteger("batchSize", batchSize);
    assertPositiveInteger("concurrency", concurrency);
    if (concurrency > batchSize) {
        throw new Error("concurrency must be less than or equal to batchSize");
    }
    assertTimerDuration("leaseMs", leaseMs);
    if (leaseMs < 2)
        throw new Error("leaseMs must be at least 2");
    assertTimerDuration("heartbeatMs", heartbeatMs);
    if (heartbeatMs >= leaseMs) {
        throw new Error("heartbeatMs must be shorter than leaseMs");
    }
    assertTimerDuration("maxActiveMs", maxActiveMs);
    return {
        batchSize,
        concurrency,
        leaseMs,
        heartbeatMs,
        maxActiveMs,
        now: options.now ?? (() => new Date()),
        wait: options.wait ?? defaultOutboxWait,
    };
}
async function notifyLeaseFailure(options, failure) {
    try {
        await options.onLeaseError?.(failure);
    }
    catch {
        // Lease observers cannot change delivery ownership or recovery behavior.
    }
}
async function notifySettlementFailure(options, failure) {
    try {
        await options.onSettlementError?.(failure);
    }
    catch {
        // Settlement observers cannot replace the unknown storage outcome.
    }
}
async function runBoundedClaimRenewal(options) {
    const remainingMs = options.deadline.getTime() - options.renewalAt.getTime();
    if (remainingMs <= 0)
        return { kind: "deadline" };
    if (options.signal.aborted)
        return { kind: "stopped" };
    const waitController = new AbortController();
    const stopWaiting = () => waitController.abort();
    options.signal.addEventListener("abort", stopWaiting, { once: true });
    const renewal = Promise.resolve()
        .then(() => options.outbox.renewClaim({
        id: options.message.id,
        claimToken: options.message.claimToken,
        now: options.renewalAt,
        leaseMs: options.runtime.leaseMs,
    }))
        .then((result) => ({ kind: "succeeded", result }), (error) => ({ kind: "failed", error }));
    const deadline = Promise.resolve()
        .then(() => options.runtime.wait(remainingMs, waitController.signal))
        .then(() => options.signal.aborted
        ? { kind: "stopped" }
        : { kind: "deadline" }, (error) => options.signal.aborted
        ? { kind: "stopped" }
        : { kind: "waitFailed", error });
    const result = await Promise.race([renewal, deadline]);
    options.signal.removeEventListener("abort", stopWaiting);
    waitController.abort();
    if (result.kind !== "succeeded")
        return result;
    try {
        const completedAt = readOutboxNow(options.runtime.now);
        if (completedAt.getTime() >= options.deadline.getTime()) {
            return { kind: "deadline" };
        }
        assertValidDate("lockedUntil", result.result.lockedUntil);
        if (result.result.lockedUntil.getTime() <= completedAt.getTime()) {
            return {
                kind: "failed",
                error: new Error(`Outbox claim renewal for message "${options.message.id}" returned an expired lease.`),
            };
        }
        return {
            kind: "succeeded",
            lockedUntil: cloneDate(result.result.lockedUntil),
        };
    }
    catch (error) {
        return { kind: "failed", error };
    }
}
function createClaimHeartbeat(options, runtime, message, activeUntil) {
    const controller = new AbortController();
    let stopped = false;
    let lockedUntil = cloneDate(message.lockedUntil);
    let renewing = false;
    let firstRenewalError;
    let terminalFailure;
    let resolveLost = () => { };
    const lost = new Promise((resolve) => {
        resolveLost = resolve;
    });
    const fail = (failure) => {
        if (terminalFailure)
            return;
        terminalFailure = failure;
        stopped = true;
        controller.abort();
        resolveLost(failure);
    };
    const run = async () => {
        try {
            let delayMs = runtime.heartbeatMs;
            while (!stopped) {
                try {
                    await runtime.wait(delayMs, controller.signal);
                }
                catch (error) {
                    if (stopped || controller.signal.aborted)
                        return;
                    fail({
                        error: new OutboxLeaseLostError({
                            id: message.id,
                            message: `Outbox heartbeat scheduling failed for message "${message.id}".`,
                            cause: error,
                        }),
                        message,
                        operation: "renewClaim",
                        state: "lost",
                        confirmedLost: false,
                    });
                    return;
                }
                if (stopped || controller.signal.aborted)
                    return;
                const renewalAt = readOutboxNow(runtime.now);
                const renewalDeadline = new Date(Math.min(lockedUntil.getTime(), activeUntil.getTime()));
                const activeDeadlineEndsFirst = activeUntil.getTime() <= lockedUntil.getTime();
                if (renewalAt.getTime() >= renewalDeadline.getTime()) {
                    fail({
                        error: activeDeadlineEndsFirst
                            ? new OutboxMaxActiveDurationError({
                                id: message.id,
                                maxActiveMs: runtime.maxActiveMs,
                            })
                            : new OutboxLeaseLostError({ id: message.id }),
                        message,
                        operation: activeDeadlineEndsFirst
                            ? "maxActiveDuration"
                            : "renewClaim",
                        state: "lost",
                        confirmedLost: !activeDeadlineEndsFirst,
                    });
                    return;
                }
                renewing = true;
                let renewal;
                try {
                    renewal = await runBoundedClaimRenewal({
                        outbox: options.outbox,
                        message,
                        runtime,
                        renewalAt,
                        deadline: renewalDeadline,
                        signal: controller.signal,
                    });
                }
                catch (error) {
                    renewal = { kind: "failed", error };
                }
                finally {
                    renewing = false;
                }
                if (renewal.kind === "stopped")
                    return;
                if (renewal.kind === "waitFailed") {
                    fail({
                        error: new OutboxLeaseLostError({
                            id: message.id,
                            message: `Could not enforce the renewal deadline for outbox message "${message.id}".`,
                            cause: renewal.error,
                        }),
                        message,
                        operation: "renewClaim",
                        state: "lost",
                        confirmedLost: false,
                    });
                    return;
                }
                if (renewal.kind === "deadline") {
                    fail({
                        error: activeDeadlineEndsFirst
                            ? new OutboxMaxActiveDurationError({
                                id: message.id,
                                maxActiveMs: runtime.maxActiveMs,
                            })
                            : new OutboxLeaseLostError({
                                id: message.id,
                                message: `Outbox claim renewal for message "${message.id}" did not complete before the lease deadline.`,
                            }),
                        message,
                        operation: activeDeadlineEndsFirst
                            ? "maxActiveDuration"
                            : "renewClaim",
                        state: "lost",
                        confirmedLost: false,
                    });
                    return;
                }
                if (renewal.kind === "succeeded") {
                    lockedUntil = renewal.lockedUntil;
                    delayMs = runtime.heartbeatMs;
                    continue;
                }
                const error = renewal.error;
                if (error instanceof OutboxClaimError) {
                    fail({
                        error,
                        message,
                        operation: "renewClaim",
                        state: "lost",
                        confirmedLost: true,
                    });
                    return;
                }
                firstRenewalError ??= error;
                if (stopped)
                    return;
                const retryAt = readOutboxNow(runtime.now);
                const remainingMs = renewalDeadline.getTime() - retryAt.getTime();
                if (remainingMs <= 0) {
                    fail({
                        error: activeDeadlineEndsFirst
                            ? new OutboxMaxActiveDurationError({
                                id: message.id,
                                maxActiveMs: runtime.maxActiveMs,
                            })
                            : new OutboxLeaseLostError({
                                id: message.id,
                                message: `Outbox claim renewal for message "${message.id}" did not recover before the lease expired.`,
                                cause: error,
                            }),
                        message,
                        operation: activeDeadlineEndsFirst
                            ? "maxActiveDuration"
                            : "renewClaim",
                        state: "lost",
                        confirmedLost: false,
                    });
                    return;
                }
                delayMs = Math.max(1, Math.min(runtime.heartbeatMs, Math.floor(remainingMs / 3)));
            }
        }
        catch (error) {
            if (stopped || controller.signal.aborted)
                return;
            fail({
                error: new OutboxLeaseLostError({
                    id: message.id,
                    message: `Outbox claim renewal failed unexpectedly for message "${message.id}".`,
                    cause: error,
                }),
                message,
                operation: "renewClaim",
                state: "lost",
                confirmedLost: false,
            });
        }
    };
    const running = run();
    const stop = async (abortRenewal = true) => {
        stopped = true;
        if (abortRenewal || !renewing)
            controller.abort();
        await running;
        controller.abort();
    };
    return {
        lost,
        stop: () => stop(true),
        async stopAndExtend() {
            await stop(false);
            if (terminalFailure) {
                return { lockedUntil, failure: terminalFailure };
            }
            const renewalAt = readOutboxNow(runtime.now);
            const activeDeadlineEndsFirst = activeUntil.getTime() <= lockedUntil.getTime();
            const finalRenewalDeadline = new Date(Math.min(lockedUntil.getTime(), activeUntil.getTime()));
            if (renewalAt.getTime() >= finalRenewalDeadline.getTime()) {
                return {
                    lockedUntil,
                    failure: {
                        error: activeDeadlineEndsFirst
                            ? new OutboxMaxActiveDurationError({
                                id: message.id,
                                maxActiveMs: runtime.maxActiveMs,
                            })
                            : new OutboxLeaseLostError({ id: message.id }),
                        message,
                        operation: activeDeadlineEndsFirst
                            ? "maxActiveDuration"
                            : "renewClaim",
                        state: "lost",
                        confirmedLost: !activeDeadlineEndsFirst,
                    },
                };
            }
            const finalController = new AbortController();
            const renewal = await runBoundedClaimRenewal({
                outbox: options.outbox,
                message,
                runtime,
                renewalAt,
                deadline: finalRenewalDeadline,
                signal: finalController.signal,
            });
            finalController.abort();
            if (renewal.kind === "succeeded") {
                return {
                    lockedUntil: renewal.lockedUntil,
                    renewalError: firstRenewalError,
                    renewalRecovered: firstRenewalError === undefined ? undefined : true,
                };
            }
            if (renewal.kind === "failed") {
                if (!(renewal.error instanceof OutboxClaimError)) {
                    const failedAt = readOutboxNow(runtime.now);
                    if (failedAt.getTime() < lockedUntil.getTime() &&
                        failedAt.getTime() < activeUntil.getTime()) {
                        return {
                            lockedUntil,
                            renewalError: renewal.error,
                            renewalRecovered: false,
                        };
                    }
                    if (activeDeadlineEndsFirst &&
                        failedAt.getTime() >= activeUntil.getTime()) {
                        return {
                            lockedUntil,
                            failure: {
                                error: new OutboxMaxActiveDurationError({
                                    id: message.id,
                                    maxActiveMs: runtime.maxActiveMs,
                                }),
                                message,
                                operation: "maxActiveDuration",
                                state: "lost",
                                confirmedLost: false,
                            },
                        };
                    }
                }
                return {
                    lockedUntil,
                    failure: {
                        error: renewal.error,
                        message,
                        operation: "renewClaim",
                        state: "lost",
                        confirmedLost: renewal.error instanceof OutboxClaimError,
                    },
                };
            }
            return {
                lockedUntil,
                failure: {
                    error: renewal.kind === "waitFailed"
                        ? new OutboxLeaseLostError({
                            id: message.id,
                            message: activeDeadlineEndsFirst
                                ? `Could not enforce the maximum active duration for outbox message "${message.id}".`
                                : `Could not enforce the final renewal deadline for outbox message "${message.id}".`,
                            cause: renewal.error,
                        })
                        : activeDeadlineEndsFirst
                            ? new OutboxMaxActiveDurationError({
                                id: message.id,
                                maxActiveMs: runtime.maxActiveMs,
                            })
                            : new OutboxLeaseLostError({
                                id: message.id,
                                message: `Outbox claim renewal for message "${message.id}" did not complete before settlement.`,
                            }),
                    message,
                    operation: activeDeadlineEndsFirst
                        ? "maxActiveDuration"
                        : "renewClaim",
                    state: "lost",
                    confirmedLost: false,
                },
            };
        },
    };
}
async function settleClaim(options) {
    let lastError;
    for (let attempt = 1; attempt <= OUTBOX_SETTLEMENT_ATTEMPTS; attempt += 1) {
        const settlementAt = readOutboxNow(options.runtime.now);
        const settlementDeadline = new Date(Math.min(options.lockedUntil.getTime(), options.activeUntil.getTime()));
        const settlement = await runBoundedSettlement({
            runtime: options.runtime,
            settlementAt,
            deadline: settlementDeadline,
            settle: options.settle,
        });
        if (settlement.kind === "succeeded") {
            return { ok: true };
        }
        if (settlement.kind === "deadline") {
            return {
                ok: false,
                error: new OutboxLeaseLostError({
                    id: options.message.id,
                    message: `Outbox ${options.operation} for message "${options.message.id}" did not complete before its settlement deadline.`,
                }),
                claimLost: true,
            };
        }
        if (settlement.kind === "waitFailed") {
            return { ok: false, error: settlement.error, claimLost: false };
        }
        const error = settlement.error;
        lastError = error;
        if (error instanceof OutboxClaimError) {
            return { ok: false, error, claimLost: true };
        }
        const remainingMs = settlementDeadline.getTime() -
            readOutboxNow(options.runtime.now).getTime();
        if (attempt >= OUTBOX_SETTLEMENT_ATTEMPTS || remainingMs <= 0)
            break;
        const waitController = new AbortController();
        try {
            await options.runtime.wait(Math.max(1, Math.min(OUTBOX_SETTLEMENT_RETRY_DELAY_MS * 2 ** (attempt - 1), remainingMs)), waitController.signal);
        }
        catch (error) {
            return { ok: false, error, claimLost: false };
        }
    }
    return { ok: false, error: lastError, claimLost: false };
}
async function runBoundedSettlement(options) {
    const remainingMs = options.deadline.getTime() - options.settlementAt.getTime();
    if (remainingMs <= 0)
        return { kind: "deadline" };
    const waitController = new AbortController();
    const settlement = Promise.resolve()
        .then(() => options.settle(options.settlementAt))
        .then(() => ({ kind: "succeeded" }), (error) => ({ kind: "failed", error }));
    const deadline = Promise.resolve()
        .then(() => options.runtime.wait(remainingMs, waitController.signal))
        .then(() => ({ kind: "deadline" }), (error) => ({ kind: "waitFailed", error }));
    const result = await Promise.race([settlement, deadline]);
    waitController.abort();
    return result;
}
async function recordDeadLetter(options, instrumentation, jobInstrumentation, message, error, details = {}) {
    try {
        await options.onDeadLetter?.(error, message);
    }
    catch {
        // Dead-letter observers must not change the settled message state.
    }
    instrumentation.record({
        type: "outbox",
        ...options.instrumentationContext,
        messageId: message.id,
        messageKind: message.kind,
        messageName: message.name,
        status: "deadLettered",
        details: outboxInstrumentationDetails(message, {
            ...details,
            error: serializeOutboxError(error),
        }),
    });
    if (message.kind === "job") {
        jobInstrumentation.record({
            type: "job",
            ...options.instrumentationContext,
            jobName: message.name,
            status: "deadLettered",
            details: outboxInstrumentationDetails(message, {
                ...details,
                error: serializeOutboxError(error),
            }),
        });
    }
}
async function processClaimedMessage(options, runtime, instrumentation, jobInstrumentation, tracing, message) {
    const emptyOutcome = () => ({
        delivered: 0,
        retried: 0,
        deadLettered: 0,
        settlementFailed: 0,
        leaseLost: 0,
    });
    const outcome = emptyOutcome();
    const startedAt = readOutboxNow(runtime.now);
    const activeUntil = new Date(startedAt.getTime() + runtime.maxActiveMs);
    const heartbeat = createClaimHeartbeat(options, runtime, message, activeUntil);
    const lifetimeController = new AbortController();
    const parentTrace = parseTraceCarrier(message.trace);
    const traceAttributes = {
        "beignet.outbox.message_kind": message.kind,
        "beignet.outbox.message_name": message.name,
    };
    const delivery = Promise.resolve()
        .then(() => runWithTracing(tracing, {
        name: `beignet.outbox deliver ${message.name}`,
        type: "outbox",
        kind: "consumer",
        parent: parentTrace,
        attributes: traceAttributes,
        metricAttributes: traceAttributes,
    }, (span) => deliverOutboxMessage(options, message, captureTraceCarrier(span?.context ?? parentTrace))))
        .then(() => ({ kind: "succeeded" }), (error) => ({ kind: "failed", error }));
    const maximumActive = Promise.resolve()
        .then(() => runtime.wait(runtime.maxActiveMs, lifetimeController.signal))
        .then(() => ({ kind: "maxActive" }), (error) => ({ kind: "waitFailed", error }));
    const leaseLost = heartbeat.lost.then((failure) => ({
        kind: "leaseLost",
        failure,
    }));
    const deliveryOutcome = await Promise.race([
        delivery,
        maximumActive,
        leaseLost,
    ]);
    lifetimeController.abort();
    if (deliveryOutcome.kind === "maxActive") {
        await heartbeat.stop();
        const failure = {
            error: new OutboxMaxActiveDurationError({
                id: message.id,
                maxActiveMs: runtime.maxActiveMs,
            }),
            message,
            operation: "maxActiveDuration",
            state: "lost",
            confirmedLost: false,
        };
        await notifyLeaseFailure(options, failure);
        instrumentation.custom({
            name: "outbox.lease.lost",
            label: "Outbox claim no longer confirmed",
            summary: `Stopped renewing ${message.kind} "${message.name}" after its active-delivery limit`,
            details: outboxInstrumentationDetails(message, {
                operation: failure.operation,
                state: failure.state,
                error: serializeOutboxError(failure.error),
            }),
        });
        outcome.leaseLost = 1;
        return outcome;
    }
    if (deliveryOutcome.kind === "waitFailed") {
        await heartbeat.stop();
        const failure = {
            error: new OutboxLeaseLostError({
                id: message.id,
                message: `Could not enforce the active-delivery limit for outbox message "${message.id}".`,
                cause: deliveryOutcome.error,
            }),
            message,
            operation: "maxActiveDuration",
            state: "lost",
            confirmedLost: false,
        };
        await notifyLeaseFailure(options, failure);
        instrumentation.custom({
            name: "outbox.lease.lost",
            label: "Outbox claim no longer confirmed",
            summary: `Could not enforce the active-delivery limit for ${message.kind} "${message.name}"`,
            details: outboxInstrumentationDetails(message, {
                operation: failure.operation,
                state: failure.state,
                error: serializeOutboxError(failure.error),
            }),
        });
        outcome.leaseLost = 1;
        return outcome;
    }
    if (deliveryOutcome.kind === "leaseLost") {
        await heartbeat.stop();
        await notifyLeaseFailure(options, deliveryOutcome.failure);
        instrumentation.custom({
            name: "outbox.lease.lost",
            label: "Outbox claim lost",
            summary: `Could not keep the claim for ${message.kind} "${message.name}" active`,
            details: outboxInstrumentationDetails(message, {
                operation: deliveryOutcome.failure.operation,
                state: deliveryOutcome.failure.state,
                confirmedLost: deliveryOutcome.failure.confirmedLost,
                error: serializeOutboxError(deliveryOutcome.failure.error),
            }),
        });
        outcome.leaseLost = 1;
        return outcome;
    }
    const lease = await heartbeat.stopAndExtend();
    if (lease.failure) {
        await notifyLeaseFailure(options, lease.failure);
        instrumentation.custom({
            name: "outbox.lease.lost",
            label: "Outbox claim lost",
            summary: `Could not confirm the claim for ${message.kind} "${message.name}" before settlement`,
            details: outboxInstrumentationDetails(message, {
                operation: lease.failure.operation,
                state: lease.failure.state,
                confirmedLost: lease.failure.confirmedLost,
                error: serializeOutboxError(lease.failure.error),
            }),
        });
        outcome.leaseLost = 1;
        return outcome;
    }
    const reportRenewalOutcome = async () => {
        if (lease.renewalError === undefined)
            return;
        const failure = {
            error: lease.renewalError,
            message,
            operation: "renewClaim",
            state: lease.renewalRecovered ? "recovered" : "degraded",
            confirmedLost: false,
        };
        await notifyLeaseFailure(options, failure);
        instrumentation.custom({
            name: lease.renewalRecovered
                ? "outbox.lease.renewal.recovered"
                : "outbox.lease.renewal.degraded",
            label: lease.renewalRecovered
                ? "Outbox claim renewal recovered"
                : "Outbox claim renewal degraded",
            summary: lease.renewalRecovered
                ? `Recovered claim renewal for ${message.kind} "${message.name}"`
                : `Continued ${message.kind} "${message.name}" settlement under its last confirmed lease`,
            details: outboxInstrumentationDetails(message, {
                state: failure.state,
                error: serializeOutboxError(lease.renewalError),
            }),
        });
    };
    if (deliveryOutcome.kind === "succeeded") {
        const settlement = await settleClaim({
            operation: "markDelivered",
            message,
            lockedUntil: lease.lockedUntil,
            activeUntil,
            runtime,
            settle: (now) => options.outbox.markDelivered({
                id: message.id,
                claimToken: message.claimToken,
                now,
            }),
        });
        await reportRenewalOutcome();
        if (!settlement.ok) {
            const failure = {
                error: settlement.error,
                message,
                operation: "markDelivered",
                deliverySucceeded: true,
            };
            await notifySettlementFailure(options, failure);
            instrumentation.custom({
                name: "outbox.settlement.failed",
                label: "Outbox settlement failed",
                summary: `Delivered ${message.kind} "${message.name}", but could not confirm its durable acknowledgement`,
                details: outboxInstrumentationDetails(message, {
                    operation: failure.operation,
                    deliverySucceeded: true,
                    settlementError: serializeOutboxError(settlement.error),
                }),
            });
            outcome.settlementFailed = 1;
            if (settlement.claimLost)
                outcome.leaseLost = 1;
            return outcome;
        }
        instrumentation.record({
            type: "outbox",
            ...options.instrumentationContext,
            messageId: message.id,
            messageKind: message.kind,
            messageName: message.name,
            status: "delivered",
            details: outboxInstrumentationDetails(message),
        });
        outcome.delivered = 1;
        return outcome;
    }
    const deliveryError = deliveryOutcome.error;
    const failedAt = readOutboxNow(runtime.now);
    const shouldRetry = shouldRetryOutboxMessage(options, message, deliveryError);
    const deadLetter = !shouldRetry;
    const retryDelayMs = deadLetter
        ? 0
        : resolveRetryDelayMs(options, message, deliveryError, failedAt);
    const retryAt = deadLetter
        ? undefined
        : new Date(failedAt.getTime() + retryDelayMs);
    const settlement = await settleClaim({
        operation: "markFailed",
        message,
        lockedUntil: lease.lockedUntil,
        activeUntil,
        runtime,
        settle: (now) => options.outbox.markFailed({
            id: message.id,
            claimToken: message.claimToken,
            error: deliveryError,
            deadLetter,
            now,
            retryAt,
        }),
    });
    await reportRenewalOutcome();
    try {
        await options.onError?.(deliveryError, message);
    }
    catch {
        // Delivery observers cannot change retry, dead-letter, or recovery state.
    }
    if (!settlement.ok) {
        const failure = {
            error: settlement.error,
            message,
            operation: "markFailed",
            deliverySucceeded: false,
            deliveryError,
        };
        await notifySettlementFailure(options, failure);
        instrumentation.custom({
            name: "outbox.settlement.failed",
            label: "Outbox settlement failed",
            summary: `Could not settle failed ${message.kind} "${message.name}"`,
            details: outboxInstrumentationDetails(message, {
                operation: failure.operation,
                deliverySucceeded: false,
                deliveryError: serializeOutboxError(deliveryError),
                settlementError: serializeOutboxError(settlement.error),
            }),
        });
        outcome.settlementFailed = 1;
        if (settlement.claimLost)
            outcome.leaseLost = 1;
        return outcome;
    }
    if (deadLetter) {
        await recordDeadLetter(options, instrumentation, jobInstrumentation, message, deliveryError);
        outcome.deadLettered = 1;
        return outcome;
    }
    instrumentation.record({
        type: "outbox",
        ...options.instrumentationContext,
        messageId: message.id,
        messageKind: message.kind,
        messageName: message.name,
        status: "retryScheduled",
        details: outboxInstrumentationDetails(message, {
            retryDelayMs,
            retryAt: retryAt?.toISOString(),
            error: serializeOutboxError(deliveryError),
        }),
    });
    if (message.kind === "job") {
        jobInstrumentation.record({
            type: "job",
            ...options.instrumentationContext,
            jobName: message.name,
            status: "retryScheduled",
            details: outboxInstrumentationDetails(message, {
                retryDelayMs,
                retryAt: retryAt?.toISOString(),
                error: serializeOutboxError(deliveryError),
            }),
        });
    }
    outcome.retried = 1;
    return outcome;
}
/**
 * Claim and deliver one bounded set of outbox messages.
 *
 * The drain claims only enough messages to fill active delivery slots and
 * renews each active claim until delivery settles or reaches its configured
 * maximum duration. It remains an at-least-once transport: a process can still
 * terminate after the external effect succeeds but before acknowledgement.
 */
export async function drainOutbox(options) {
    const runtime = resolveDrainRuntime(options);
    assertOutboxDrainPort(options.outbox);
    assertOutboxDeliveryCapabilities(options);
    const instrumentation = createProviderInstrumentation(options.instrumentation, {
        providerName: "outbox",
        watcher: "outbox",
    });
    const jobInstrumentation = createProviderInstrumentation(options.instrumentation, {
        providerName: "outbox",
        watcher: "jobs",
    });
    const tracing = resolveTracingPort(options.instrumentation);
    const result = {
        claimed: 0,
        delivered: 0,
        retried: 0,
        deadLettered: 0,
        abandonedDeadLettered: 0,
        settlementFailed: 0,
        leaseLost: 0,
    };
    let remaining = runtime.batchSize;
    let sourceExhausted = false;
    while (remaining > 0 && !sourceExhausted) {
        const active = [];
        const abandoned = [];
        while (active.length < runtime.concurrency && remaining > 0) {
            const limit = Math.min(runtime.concurrency - active.length, remaining);
            const selected = await options.outbox.claimBatch({
                limit,
                now: readOutboxNow(runtime.now),
                leaseMs: runtime.leaseMs,
            });
            const selectedCount = selected.claimed.length + selected.deadLettered.length;
            if (selectedCount === 0) {
                sourceExhausted = true;
                break;
            }
            if (selectedCount > limit) {
                throw new Error(`Outbox claimBatch returned ${selectedCount} messages for a limit of ${limit}.`);
            }
            remaining -= selectedCount;
            result.claimed += selected.claimed.length;
            active.push(...selected.claimed);
            abandoned.push(...selected.deadLettered);
        }
        // Construct active delivery promises first so their heartbeats protect
        // freshly claimed rows while abandoned-message observers run.
        const outcomePromises = active.map((message) => processClaimedMessage(options, runtime, instrumentation, jobInstrumentation, tracing, message));
        const abandonedPromises = abandoned.map(async (message) => {
            const error = new OutboxAbandonedClaimError({
                id: message.id,
                attempts: message.attempts,
                maxAttempts: message.maxAttempts,
            });
            await recordDeadLetter(options, instrumentation, jobInstrumentation, message, error, { abandoned: true });
        });
        const [outcomes] = await Promise.all([
            Promise.all(outcomePromises),
            Promise.all(abandonedPromises),
        ]);
        result.deadLettered += abandoned.length;
        result.abandonedDeadLettered += abandoned.length;
        for (const outcome of outcomes) {
            result.delivered += outcome.delivered;
            result.retried += outcome.retried;
            result.deadLettered += outcome.deadLettered;
            result.settlementFailed += outcome.settlementFailed;
            result.leaseLost += outcome.leaseLost;
        }
    }
    return result;
}
function assertOutboxDrainPort(outbox) {
    const candidate = outbox;
    const missing = [
        "claimBatch",
        "renewClaim",
        "markDelivered",
        "markFailed",
    ].filter((method) => typeof candidate[method] !== "function");
    if (missing.length > 0) {
        throw new Error(`Cannot drain this outbox: the outbox port is missing ${missing
            .map((method) => `${method}()`)
            .join(", ")}.`);
    }
}
function assertOutboxDeliveryCapabilities(options) {
    const missing = [];
    if (options.registry.events.size > 0 && !options.eventBus) {
        missing.push("events require an event bus");
    }
    if (options.registry.jobs.size > 0 && !options.jobs) {
        missing.push("jobs require a job dispatcher");
    }
    if (missing.length > 0) {
        throw new OutboxRegistryError(`Cannot drain this outbox registry: ${missing.join("; ")}.`);
    }
}
//# sourceMappingURL=index.js.map