/**
 * chunks-subscriber-transport.ts — Phase B B1 transport module.
 *
 * Long-poll loop against host-cp's /v1/shape that delivers chunks
 * to a subscriber via `onChunk` callback. Owns: bearer auth header,
 * reconnect-on-error with exponential backoff (capped 2s), AbortSignal
 * cancellation, and Electric SQL shape protocol parsing (handle / offset
 * round-tripping for incremental delivery).
 *
 * Boundary discipline (per design doc `transport` + `subscriber`):
 *   - Transport: auth + reconnect + delivery. NO filtering / decision /
 *     dispatch — that's the subscriber's job (`operator-subscription.ts`
 *     pure helpers + per-persona runners).
 *   - One transport instance per subscriber process; ≤5 long-polls per
 *     session is acceptable scale.
 *
 * AbortSignal wiring (per A1.4 §transport defense-in-depth):
 *   - Both the long-poll wait AND the reconnect-backoff sleep honor
 *     the injected AbortSignal.
 *   - Per-child SIGTERM handler aborts → transport releases within
 *     milliseconds (not at the 2s backoff cap).
 *   - The same signal feeds into the SDK's query() AbortController
 *     in B5 driver-hot-loop so one abort releases both.
 *
 * Source: docs/design/olam-plan-chat-agent-runtime.md `transport` section.
 */
/**
 * Sleep with optional abort. Resolves on timer OR rejects on abort.
 */
async function abortableSleep(ms, signal) {
    if (signal?.aborted)
        throw new DOMException('aborted', 'AbortError');
    return new Promise((resolve, reject) => {
        const t = setTimeout(() => {
            signal?.removeEventListener('abort', onAbort);
            resolve();
        }, ms);
        const onAbort = () => {
            clearTimeout(t);
            reject(new DOMException('aborted', 'AbortError'));
        };
        signal?.addEventListener('abort', onAbort, { once: true });
    });
}
function isAbortError(err) {
    return (err instanceof Error &&
        (err.name === 'AbortError' || err.code === 'ABORT_ERR'));
}
/**
 * Parse Electric SQL shape response messages into chunk rows.
 * Shape protocol: array of `{ headers, value, ... }` messages; we only
 * care about `value` payloads with `operation: 'insert'` (or no operation
 * field on the initial snapshot).
 */
export function parseShapeMessages(body) {
    if (!Array.isArray(body))
        return [];
    const out = [];
    for (const msg of body) {
        if (!msg || typeof msg !== 'object')
            continue;
        const headers = msg.headers;
        const op = headers?.operation;
        // Initial snapshot has no operation; live inserts have operation='insert'.
        // Delete + update aren't meaningful for append-only chunks but we skip them defensively.
        if (op && op !== 'insert')
            continue;
        const value = msg.value;
        if (!value || typeof value !== 'object')
            continue;
        // Electric SQL returns integer columns as STRINGS in the JSON wire format
        // ('0' not 0). Coerce here so downstream `filterOperatorPickups` cursor
        // comparisons (`c.seq <= lastConsumedSeq`) work numerically. Without this
        // coercion EVERY row was rejected by the `typeof === 'number'` guard and
        // the driver hung silently in iterationLoop waiting for onChunk to fire.
        const v = value;
        const rawSeq = v.seq;
        const seq = typeof rawSeq === 'number'
            ? rawSeq
            : typeof rawSeq === 'string'
                ? Number.parseInt(rawSeq, 10)
                : NaN;
        if (typeof v.world_id === 'string' &&
            typeof v.session_id === 'string' &&
            typeof v.message_id === 'string' &&
            Number.isFinite(seq) &&
            typeof v.actor_id === 'string' &&
            typeof v.actor_type === 'string' &&
            typeof v.role === 'string' &&
            typeof v.chunk === 'string' &&
            typeof v.created_at === 'string') {
            out.push({
                world_id: v.world_id,
                session_id: v.session_id,
                message_id: v.message_id,
                seq,
                actor_id: v.actor_id,
                actor_type: v.actor_type,
                role: v.role,
                chunk: v.chunk,
                created_at: v.created_at,
            });
        }
    }
    return out;
}
/**
 * Extract Electric shape cursor from response headers.
 * `electric-handle` + `electric-offset` per the protocol;
 * absent on errors or non-shape responses.
 */
export function extractCursor(headers) {
    return {
        handle: headers.get('electric-handle'),
        offset: headers.get('electric-offset'),
    };
}
/**
 * Build the /v1/shape URL with cursor + live=true on subsequent polls.
 */
export function buildShapeUrl(baseUrl, worldId, sessionId, cursor) {
    const url = new URL('/v1/shape', baseUrl);
    // Electric SQL requires the `table` query param; plan-chat-service strips
    // world_id/session_id/where (per SCOPED_QUERY_PARAMS) but forwards everything
    // else unchanged, so `table=chunks` is the client's responsibility. Without
    // it, Electric upstream returns 400 `{"errors":{"table":["can't be blank"]}}`.
    url.searchParams.set('table', 'chunks');
    url.searchParams.set('world_id', worldId);
    url.searchParams.set('session_id', sessionId);
    if (cursor.handle) {
        url.searchParams.set('handle', cursor.handle);
        url.searchParams.set('offset', cursor.offset ?? '-1');
        url.searchParams.set('live', 'true');
    }
    else {
        url.searchParams.set('offset', '-1');
    }
    return url.toString();
}
/**
 * Exponential backoff schedule: 100ms → 200ms → 400ms → 800ms → 1600ms → 2000ms (cap).
 * Returns the delay for the given attempt index (0-based).
 */
export function backoffDelay(attempt) {
    const base = 100 * Math.pow(2, attempt);
    return Math.min(base, 2000);
}
/**
 * subscribeToChunks — start a long-poll subscription. Returns a handle
 * with `stop()` (abortable) and `done` (resolves when loop exits).
 *
 * Failure semantics:
 *   - Network/5xx errors: exponential backoff up to 2s cap; retry forever
 *     until abort signal fires.
 *   - 401: re-authenticate is OUT-OF-SCOPE for v1; transport surfaces a
 *     terminal error via `done` rejection. (B9 JWT refresh handles this
 *     in the post-migration world.)
 *   - 4xx (other): terminal; reject `done`.
 *   - Abort signal: clean shutdown; `done` resolves (not rejects).
 */
export function subscribeToChunks(opts) {
    const { baseUrl, bearer, worldId, sessionId, onChunk, abortSignal: externalSignal, fetchImpl = fetch, sleepImpl = abortableSleep, } = opts;
    // Internal controller composes with the external signal so stop() works
    // even when no external abortSignal is provided.
    const controller = new AbortController();
    if (externalSignal) {
        if (externalSignal.aborted)
            controller.abort();
        else
            externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
    }
    const signal = controller.signal;
    const cursor = { handle: null, offset: null };
    let attempt = 0;
    const loop = async () => {
        while (!signal.aborted) {
            try {
                const url = buildShapeUrl(baseUrl, worldId, sessionId, cursor);
                const res = await fetchImpl(url, {
                    method: 'GET',
                    headers: {
                        authorization: `Bearer ${bearer}`,
                        accept: 'application/json',
                    },
                    signal,
                });
                if (res.status === 401) {
                    // Terminal: shared-secret rejected or JWT scope-mismatch (B9 future).
                    throw new Error(`subscribeToChunks: 401 unauthorized at ${url}`);
                }
                if (res.status >= 400 && res.status < 500) {
                    throw new Error(`subscribeToChunks: ${res.status} ${res.statusText} at ${url}`);
                }
                if (!res.ok) {
                    // 5xx → backoff retry below
                    throw new Error(`subscribeToChunks: ${res.status} at ${url}`);
                }
                // Advance cursor BEFORE deliver so a slow onChunk callback doesn't re-fetch the same shape.
                const newCursor = extractCursor(res.headers);
                if (newCursor.handle)
                    cursor.handle = newCursor.handle;
                if (newCursor.offset)
                    cursor.offset = newCursor.offset;
                const body = await res.json().catch(() => null);
                const chunks = parseShapeMessages(body);
                for (const chunk of chunks) {
                    if (signal.aborted)
                        return;
                    await onChunk(chunk);
                }
                // Successful round-trip: reset backoff. In production, the next
                // iteration's fetch blocks on Electric's `live=true` long-poll
                // until new data arrives. If the upstream returns immediately
                // with empty data (misconfigured shape, network proxy that
                // doesn't honor long-poll, or mock fetch in tests), yield briefly
                // so we don't CPU-burn. 100ms matches the smallest backoff step;
                // imperceptible to operators since real long-polling already
                // dominates wait time.
                attempt = 0;
                if (chunks.length === 0) {
                    try {
                        await sleepImpl(100, signal);
                    }
                    catch {
                        return;
                    }
                }
            }
            catch (err) {
                if (isAbortError(err) || signal.aborted)
                    return;
                // 4xx terminal: propagate (will reject `done`).
                if (err instanceof Error && /^subscribeToChunks: 4\d\d/.test(err.message)) {
                    throw err;
                }
                // 5xx / network → backoff + retry.
                const delay = backoffDelay(attempt++);
                try {
                    await sleepImpl(delay, signal);
                }
                catch {
                    // Abort during backoff sleep is a clean exit.
                    return;
                }
            }
        }
    };
    const done = loop();
    return {
        stop: async () => {
            controller.abort();
            try {
                await done;
            }
            catch {
                // stop() never throws; the original `done` promise still carries the error.
            }
        },
        done,
    };
}
//# sourceMappingURL=chunks-subscriber-transport.js.map