'use strict';
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.DeploymentRecorder = void 0;
exports.getActiveEmitter = getActiveEmitter;
exports.awaitDeploymentRow = awaitDeploymentRow;
// DeploymentRecorder — lifecycle owner for one row in system.hdb_deployment.
//
// Creates the pending row at deploy start, persists the upload payload into the row's
// payload_blob (with sha256 + size), and writes the terminal status at the end.
// Subscribes to a ProgressEmitter so phase transitions and install lines land in
// event_log as they happen — making the deploy observable by Studio polling
// get_deployment without an attached CLI. The persisted payload_blob will also serve
// as the rollback source when that operation lands.
const node_crypto_1 = require("node:crypto");
const node_crypto_2 = require("node:crypto");
const databases_ts_1 = require("../resources/databases.js");
const blob_ts_1 = require("../resources/blob.js");
const terms = __importStar(require("../utility/hdbTerms.js"));
const hdbError_ts_1 = require("../utility/errors/hdbError.js");
const node_os_1 = require("node:os");
// Bound the event_log so a pathologically chatty install can't grow a row without limit.
// 200 entries comfortably covers a real deploy with headroom (phase events plus install
// line summaries). When we exceed the cap, drop the middle rather than the front — the
// lifecycle spine (prepare → load → replicate → success) is the most valuable context
// for debugging, and naive front-truncation loses it under a chatty `npm install`.
const EVENT_LOG_MAX = 200;
const EVENT_LOG_HEAD_KEEP = 20;
// In-memory registry of live emitters, keyed by deployment_id. Populated for the lifetime
// of an in-progress deploy on the origin node; get_deployment SSE looks here to tail live
// events after replaying event_log. Per-node, not replicated — peers don't see another
// node's in-progress emitters. Cross-node tailing is a later concern.
const activeEmitters = new Map();
function getActiveEmitter(deploymentId) {
    return activeEmitters.get(deploymentId);
}
// Interim cap: ingestPayload currently buffers the entire payload in memory before
// computing the hash and persisting. This cap prevents an OOM on accidentally-huge
// uploads. A follow-up will swap this for a true streaming-hash + blob-source pattern
// that lifts the limit back to whatever the replication path supports.
const PAYLOAD_BUFFER_CAP_BYTES = 200 * 1024 * 1024;
class DeploymentRecorder {
    deploymentId;
    record;
    hash = null;
    byteCount = 0;
    finished = false;
    unsubscribe = null;
    pendingPut = null;
    dirty = false;
    sealed = false;
    constructor(deploymentId, initial) {
        this.deploymentId = deploymentId;
        this.record = initial;
    }
    static async create(options) {
        const deploymentId = (0, node_crypto_1.randomUUID)();
        const startedAt = Date.now();
        const record = {
            deployment_id: deploymentId,
            project: options.project ?? null,
            package_identifier: options.package_identifier ?? null,
            payload_hash: null,
            payload_size: null,
            payload_blob: null,
            status: 'pending',
            phase: 'pending',
            event_log: [],
            peer_results: [],
            origin_node: (0, node_os_1.hostname)(),
            restart_mode: options.restart_mode ?? null,
            started_at: startedAt,
            completed_at: null,
            user: options.user ?? null,
            rollback_of: options.rollback_of ?? null,
            error: null,
        };
        const recorder = new DeploymentRecorder(deploymentId, record);
        await recorder.put();
        if (options.emitter) {
            recorder.subscribeTo(options.emitter);
            activeEmitters.set(deploymentId, options.emitter);
        }
        return recorder;
    }
    /**
     * Subscribe to a ProgressEmitter. Each event is appended (bounded) to event_log; phase
     * events also update the row's `status` and `phase` fields. Writes coalesce: a put is
     * always pending after the first event in a burst, so chatty install output collapses
     * to one row update per ~100ms instead of one per line.
     */
    subscribeTo(emitter) {
        this.unsubscribe = emitter.subscribe((event) => {
            this.appendEvent(event.event, event.data);
        });
    }
    appendEvent(event, data) {
        if (this.finished)
            return;
        const log = this.record.event_log;
        log.push({ t: Date.now(), event, data });
        // Keep the head (lifecycle spine) and tail (most-recent activity); drop the middle.
        if (log.length > EVENT_LOG_MAX) {
            const tailKeep = EVENT_LOG_MAX - EVENT_LOG_HEAD_KEEP - 1; // -1 for the truncation marker
            const removedCount = log.length - EVENT_LOG_HEAD_KEEP - tailKeep;
            log.splice(EVENT_LOG_HEAD_KEEP, log.length - EVENT_LOG_HEAD_KEEP - tailKeep, {
                t: Date.now(),
                event: 'truncated',
                data: { dropped_events: removedCount },
            });
        }
        // Phase events drive the canonical status/phase fields used by list_deployments.
        if (event === 'phase' && data && typeof data === 'object') {
            const p = data;
            if (p.phase)
                this.record.phase = p.phase;
            if (p.status === 'start') {
                const mapped = startStatusFor(p.phase);
                if (mapped)
                    this.record.status = mapped;
            }
        }
        this.scheduleFlush();
    }
    // Coalesce writes: at most one in-flight put at a time. While a put is running, mark
    // the record dirty; the chained continuation issues a follow-up put once the prior one
    // settles. This keeps event_log writes O(1) puts per burst rather than O(N) per event.
    scheduleFlush() {
        if (this.sealed) {
            // Sealed: accumulate state in memory but don't write. finish() does the single
            // terminal write. See seal() for why. The emitter still emits live SSE events.
            this.dirty = true;
            return;
        }
        if (this.pendingPut) {
            this.dirty = true;
            return;
        }
        this.pendingPut = this.put().finally(() => {
            this.pendingPut = null;
            if (this.dirty) {
                this.dirty = false;
                this.scheduleFlush();
            }
        });
    }
    /**
     * Drain a payload source (Buffer or Readable) into the row's payload_blob attribute,
     * computing sha256 and byte count alongside. After this resolves the row has been
     * committed once with the final hash and size, and `this.row.payload_blob.stream()`
     * yields a fresh Readable that callers can pass to extraction.
     *
     * Buffers the payload in memory (subject to PAYLOAD_BUFFER_CAP_BYTES) so the
     * hash/size are known synchronously before we commit and so the blob's `saveBlob`
     * lifecycle doesn't race with our digest() call. A streaming variant is a planned
     * follow-up that uses the unused `hash`/`byteCount` instance fields below.
     */
    async ingestPayload(source) {
        const hash = (0, node_crypto_2.createHash)('sha256');
        let byteCount = 0;
        let buffer;
        if (Buffer.isBuffer(source)) {
            buffer = source;
        }
        else if (typeof source === 'string') {
            // Legacy CBOR/JSON path: payload arrives as a base64-encoded string.
            buffer = Buffer.from(source, 'base64');
        }
        else {
            const chunks = [];
            let collected = 0;
            for await (const chunk of source) {
                const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
                collected += buf.length;
                if (collected > PAYLOAD_BUFFER_CAP_BYTES) {
                    source.destroy?.();
                    throw new hdbError_ts_1.ClientError(`Deploy payload exceeds the ${PAYLOAD_BUFFER_CAP_BYTES} byte buffer cap. ` +
                        `Use a package identifier (npm:/file:/git:) for larger components.`);
                }
                chunks.push(buf);
            }
            buffer = Buffer.concat(chunks);
        }
        if (buffer.length > PAYLOAD_BUFFER_CAP_BYTES) {
            throw new hdbError_ts_1.ClientError(`Deploy payload (${buffer.length} bytes) exceeds the ${PAYLOAD_BUFFER_CAP_BYTES} byte buffer cap. ` +
                `Use a package identifier (npm:/file:/git:) for larger components.`);
        }
        hash.update(buffer);
        byteCount = buffer.length;
        this.record.payload_blob = (0, blob_ts_1.createBlob)(buffer, { type: 'application/gzip' });
        this.record.payload_hash = hash.digest('hex');
        this.record.payload_size = byteCount;
        // Touch the unused private fields so the type system stays happy when a streaming
        // variant lands that uses them.
        this.hash = hash;
        this.byteCount = byteCount;
        await this.put();
    }
    async transitionPhase(phase, status) {
        this.record.phase = phase;
        if (status)
            this.record.status = status;
        await this.put();
    }
    /**
     * Upsert a single peer outcome by node name. Called per-peer as each replication
     * target settles, so the row reflects in-flight progress rather than only the
     * final aggregate. Routes through `scheduleFlush()` so the write coalesces with
     * the emitter-driven puts and the latest in-memory state always wins.
     *
     * Tolerates unknown shapes — anything we can't interpret becomes a plain
     * stringified entry so the audit trail at least records that a peer was contacted.
     */
    recordPeer(result) {
        if (this.finished)
            return;
        const normalized = normalizePeerResult(result);
        // Defensive: rows freshly created via create() always have peer_results=[], but if
        // a replicated row was loaded back where the attribute is absent or null we want
        // to initialize lazily rather than throw on `.findIndex`.
        if (!Array.isArray(this.record.peer_results))
            this.record.peer_results = [];
        const list = this.record.peer_results;
        // Upsert by node name when present; otherwise append (we can't dedupe without an id).
        const nodeName = normalized.node;
        const idx = nodeName ? list.findIndex((entry) => entry.node === nodeName) : -1;
        if (idx >= 0) {
            list[idx] = normalized;
        }
        else {
            list.push(normalized);
        }
        this.scheduleFlush();
    }
    /**
     * Bulk-record a final aggregate of peer outcomes. Equivalent to calling recordPeer()
     * for each entry. Useful for replication layers that surface results all-at-once
     * via Promise.allSettled rather than via a per-peer callback.
     */
    recordPeers(results) {
        if (this.finished)
            return;
        if (!Array.isArray(results))
            return;
        for (const result of results)
            this.recordPeer(result);
    }
    /**
     * Stop persisting intermediate row updates; accumulate them in memory so finish() writes
     * the terminal state in a single put. Called before the replicate phase, where the row
     * otherwise receives a tight burst of puts (replicate phase + per-peer + finish) within
     * a few ms. That burst can commit out of order on a loaded peer, where an older full
     * update reverts the terminal `success` write — the row stays stuck at `replicating` and
     * never converges (harperdb/harper#1170). Collapsing to one terminal write isolates it
     * from any concurrent same-key write so the receiver converges.
     *
     * Tradeoff: the origin's get_deployment *polling* view skips the transient `replicating`
     * status and incremental peer_results during the final phase; live SSE tailing is
     * unaffected (the emitter still emits in real time). Once #1170 lands this seal can be
     * removed to restore incremental peer_results persistence.
     */
    seal() {
        this.sealed = true;
    }
    async finish(status, error) {
        if (this.finished)
            return;
        // Send a terminal sentinel through the emitter (if any) BEFORE we unsubscribe and
        // remove it from the registry, so any SSE tail subscribers can resolve their wait
        // even on a code path that doesn't emit an explicit `error` event.
        const emitter = activeEmitters.get(this.deploymentId);
        emitter?.emit('_recorder_finished', { status });
        this.finished = true;
        this.unsubscribe?.();
        this.unsubscribe = null;
        activeEmitters.delete(this.deploymentId);
        // Drain the ENTIRE coalesced-flush chain before mutating + persisting the terminal
        // state. Just awaiting `this.pendingPut` once isn't enough: its `.finally` may
        // re-schedule another put (when `dirty` was set during the in-flight put), and
        // that re-scheduled put captures a pre-mutation snapshot of the record. Without
        // this loop, the re-scheduled put can complete AFTER our terminal put and
        // overwrite status=success with stale state.
        while (this.pendingPut) {
            try {
                await this.pendingPut;
            }
            catch {
                /* the next put surfaces the error */
            }
        }
        this.record.status = status;
        this.record.completed_at = Date.now();
        if (error) {
            const e = error;
            this.record.error = {
                message: e?.message ?? String(error),
                code: e?.code,
                phase: this.record.phase,
            };
        }
        await this.put();
    }
    get row() {
        return this.record;
    }
    async put() {
        const table = databases_ts_1.databases.system?.[terms.SYSTEM_TABLE_NAMES.DEPLOYMENT_TABLE_NAME];
        if (!table) {
            // Table missing means the upgrade directive hasn't run yet (or the table got dropped).
            // We tolerate this — tracking is observability; the deploy itself must still succeed.
            return;
        }
        await table.put(this.record);
    }
}
exports.DeploymentRecorder = DeploymentRecorder;
/**
 * Peer-side helper — wait for the hdb_deployment row to arrive via table replication,
 * then return it. The row is committed on origin before `replicateOperation` is
 * called, so peers normally find it immediately; this polling loop is for the rare
 * case where the operation arrives faster than the table-replication channel.
 *
 * The payload_blob's chunks may still be in flight after the row arrives — that's
 * fine, the Blob's `stream()` / `bytes()` API blocks on incomplete writes
 * (resources/blob.ts).
 */
async function awaitDeploymentRow(deploymentId, options = {}) {
    const timeoutMs = options.timeoutMs ?? 30_000;
    const maxIntervalMs = options.pollIntervalMs ?? 100;
    // Start fast (5ms) so the common case — replication has already caught up — sees no
    // human-noticeable latency, then back off exponentially up to maxIntervalMs for the
    // rare case where the row is genuinely still replicating.
    let intervalMs = options.initialPollIntervalMs ?? 5;
    const table = databases_ts_1.databases.system?.[terms.SYSTEM_TABLE_NAMES.DEPLOYMENT_TABLE_NAME];
    if (!table) {
        throw new Error(`Deployment tracking is not initialized on this node (system.${terms.SYSTEM_TABLE_NAMES.DEPLOYMENT_TABLE_NAME} missing).`);
    }
    const deadline = Date.now() + timeoutMs;
    let lastError;
    while (Date.now() < deadline) {
        try {
            const row = await table.get(deploymentId);
            if (row && row.payload_blob != null)
                return row;
        }
        catch (err) {
            lastError = err;
        }
        await new Promise((resolve) => setTimeout(resolve, intervalMs));
        intervalMs = Math.min(intervalMs * 2, maxIntervalMs);
    }
    throw new Error(`Timed out after ${timeoutMs}ms waiting for hdb_deployment row '${deploymentId}' to replicate` +
        (lastError ? ` (last error: ${lastError.message ?? lastError})` : ''));
}
function normalizePeerResult(raw) {
    if (!raw || typeof raw !== 'object') {
        // Replication layer returned a primitive — preserve as a stringified marker so the
        // audit row at least records that something came back from a peer.
        return { node: null, status: 'unknown', raw: String(raw) };
    }
    const r = raw;
    const err = r.error;
    const hasError = err != null && (typeof err === 'string' ? err.length > 0 : typeof err === 'object' || typeof err === 'number');
    return {
        node: r.node ?? r.name ?? r.hostname ?? null,
        status: hasError ? 'failed' : (r.status ?? 'success'),
        error: hasError
            ? {
                message: typeof err === 'object' ? (err.message ?? String(err)) : String(err),
                code: typeof err === 'object' ? err.code : undefined,
            }
            : null,
        started_at: r.started_at ?? null,
        completed_at: r.completed_at ?? null,
    };
}
function startStatusFor(phase) {
    switch (phase) {
        case 'extract':
            return 'extracting';
        case 'install':
            return 'installing';
        case 'load':
            return 'loading';
        case 'replicate':
            return 'replicating';
        case 'restart':
            return 'restarting';
        default:
            return null;
    }
}
//# sourceMappingURL=deploymentRecorder.js.map