// Epic #532 / Issue #539 — relationship engine store layer.
//
// Module-scope SQL constants only; NO string interpolation into SQL; every read and write
// is workspace-scoped (storage.md §3.3, audit-events.md §10). Mutations run in a single
// transaction together with their audit row write (storage.md §4); the audit-row writer
// lives in `./relationship-audit.ts` and is invoked by the API layer inside the same
// BEGIN..COMMIT block.
//
// This file owns the lifecycle of the `relationships` and `relationship_lifecycle_history`
// tables only. The validator (#538) is pure and lives in `@oscharko-dev/keiko-contracts`;
// the API layer (`../relationship-handlers.ts`) composes the validator with this store.
//
// Bounded-query caps mirror api-contract.md §7. Hard caps are exported as `const` so the
// handlers cite the same numbers.
import { randomUUID } from "node:crypto";
import { RELATIONSHIP_SCHEMA_VERSION, RELATIONSHIP_SUPPORTED_OBJECT_KINDS, } from "@oscharko-dev/keiko-contracts";
import { invalidRequest, notFound, UiStoreError } from "./errors.js";
// ─── Bounded-query caps (api-contract.md §7) ──────────────────────────────────
export const MAX_LIST_LIMIT = 256;
export const DEFAULT_LIST_LIMIT = 64;
export const MAX_IMPACT_DEPTH = 3;
export const DEFAULT_IMPACT_DEPTH = 1;
export const MAX_IMPACT_NODES = 1024;
export const DEFAULT_IMPACT_NODES = 256;
export const MAX_IMPACT_RELATIONSHIPS = 2048;
export const DEFAULT_IMPACT_RELATIONSHIPS = 512;
export const LIFECYCLE_HISTORY_RETAIN = 32;
// Alias used by #542 health surface — counts in every finding category are hard-capped at
// this number. It is the same hard cap as MAX_IMPACT_RELATIONSHIPS; the alias is exported
// so callers can name the contract intent.
export const MAX_RELATIONSHIPS_PER_QUERY = MAX_IMPACT_RELATIONSHIPS;
function rebuildScope(row) {
    const wsId = row.workspace_scope_id;
    switch (row.scope_kind) {
        case "user":
            return { kind: "user", userId: row.scope_coordinate, workspaceId: wsId };
        case "workspace":
            return { kind: "workspace", workspaceId: row.scope_coordinate };
        case "project":
            return { kind: "project", projectId: row.scope_coordinate, workspaceId: wsId };
        case "workflow":
            return {
                kind: "workflow",
                workflowDefinitionId: row.scope_coordinate,
                workspaceId: wsId,
            };
        case "global":
            return { kind: "global", workspaceId: wsId };
        default:
            // STRICT mode + CHECK constraint at the schema layer prevent this; surfaced as a typed
            // error so callers never see a partial row silently.
            throw new UiStoreError("internal", "Unknown relationship scope kind.", 500);
    }
}
function rowToRelationship(row) {
    const stored = {
        id: row.id,
        schemaVersion: RELATIONSHIP_SCHEMA_VERSION,
        workspaceId: row.workspace_scope_id,
        source: {
            kind: row.source_kind,
            id: row.source_id,
            workspaceId: row.workspace_scope_id,
        },
        target: {
            kind: row.target_kind,
            id: row.target_id,
            workspaceId: row.workspace_scope_id,
        },
        type: row.type,
        lifecycleState: row.lifecycle,
        createdAt: new Date(row.created_at).toISOString(),
        updatedAt: new Date(row.updated_at).toISOString(),
        etag: row.updated_at, // legacy numeric etag in the contract; we expose the opaque string via getRelationshipEtag(id, workspaceId)
        scope: rebuildScope(row),
        ...(row.confidence === null ? {} : { confidence: row.confidence }),
        ...(row.summary === null ? {} : { summary: row.summary }),
    };
    return stored;
}
// ─── SQL ──────────────────────────────────────────────────────────────────────
const SQL_INSERT = `
INSERT INTO relationships(
  id, schema_version, workspace_scope_id, scope_kind, scope_coordinate, type,
  source_kind, source_id, target_kind, target_id, lifecycle,
  created_at, updated_at, etag, confidence, summary
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const SQL_GET = `
SELECT id, schema_version, workspace_scope_id, scope_kind, scope_coordinate, type,
       source_kind, source_id, target_kind, target_id, lifecycle,
       created_at, updated_at, etag, confidence, summary
FROM relationships
WHERE id = ? AND workspace_scope_id = ?
`;
const SQL_GET_ETAG_SCOPED = "SELECT etag FROM relationships WHERE id = ? AND workspace_scope_id = ?";
const SQL_UPDATE_LIFECYCLE = `
UPDATE relationships
SET lifecycle = ?, updated_at = ?, etag = ?, summary = COALESCE(?, summary)
WHERE id = ? AND workspace_scope_id = ?
`;
const SQL_UPDATE_RECONNECT = `
UPDATE relationships
SET target_kind = ?, target_id = ?, updated_at = ?, etag = ?, summary = COALESCE(?, summary)
WHERE id = ? AND workspace_scope_id = ?
`;
const SQL_COUNT_PRODUCES_EVIDENCE_FOR_SOURCE = `
SELECT COUNT(*) AS n FROM relationships
WHERE workspace_scope_id = ? AND type = 'produces-evidence'
  AND source_kind = ? AND source_id = ?
  AND lifecycle IN ('draft','active','archived')
`;
const SQL_COUNT_STARTS_WORKFLOW_FOR_TARGET = `
SELECT COUNT(*) AS n FROM relationships
WHERE workspace_scope_id = ? AND type = 'starts-workflow'
  AND target_kind = ? AND target_id = ?
  AND lifecycle IN ('draft','active','archived')
`;
const SQL_INSERT_HISTORY = `
INSERT INTO relationship_lifecycle_history(id, relationship_id, from_state, to_state, occurred_at, summary)
VALUES (?, ?, ?, ?, ?, ?)
`;
const SQL_LIST_HISTORY = `
SELECT relationship_id, from_state, to_state, occurred_at, summary
FROM relationship_lifecycle_history
WHERE relationship_id = ?
ORDER BY occurred_at DESC
LIMIT ?
`;
const SQL_FIND_BY_SOURCE = `
SELECT id, schema_version, workspace_scope_id, scope_kind, scope_coordinate, type,
       source_kind, source_id, target_kind, target_id, lifecycle,
       created_at, updated_at, etag, confidence, summary
FROM relationships
WHERE workspace_scope_id = ? AND source_kind = ? AND source_id = ?
ORDER BY updated_at DESC, id ASC
LIMIT ?
`;
const SQL_FIND_BY_TARGET = `
SELECT id, schema_version, workspace_scope_id, scope_kind, scope_coordinate, type,
       source_kind, source_id, target_kind, target_id, lifecycle,
       created_at, updated_at, etag, confidence, summary
FROM relationships
WHERE workspace_scope_id = ? AND target_kind = ? AND target_id = ?
ORDER BY updated_at DESC, id ASC
LIMIT ?
`;
const SQL_HEALTH_COUNTS = `
SELECT lifecycle, COUNT(*) AS n FROM relationships
WHERE workspace_scope_id = ?
GROUP BY lifecycle
`;
// Stale / blocked / revoked / invalid SQL — each is workspace-scoped, hard-capped, and
// deterministically ordered by (lifecycle, source_kind, source_id, target_kind, target_id).
// The limit is bound at execute-time; no user input enters SQL text.
const SQL_HEALTH_FINDINGS_BY_LIFECYCLE = `
SELECT id, type, source_kind, source_id, target_kind, target_id, lifecycle
FROM relationships
WHERE workspace_scope_id = ? AND lifecycle = ?
ORDER BY source_kind ASC, source_id ASC, target_kind ASC, target_id ASC, id ASC
LIMIT ?
`;
// Active relationships used for cycle detection + invalid-reference detection. Bounded by
// the same cap; we cap both the scan input and the result list at MAX_RELATIONSHIPS_PER_QUERY
// so an oversized graph degrades gracefully via `cycleScanTruncated = true`.
const SQL_HEALTH_ACTIVE_RELATIONSHIPS = `
SELECT id, type, source_kind, source_id, target_kind, target_id, lifecycle
FROM relationships
WHERE workspace_scope_id = ? AND lifecycle IN ('draft','active','archived')
ORDER BY source_kind ASC, source_id ASC, target_kind ASC, target_id ASC, id ASC
LIMIT ?
`;
// Endpoint participation count. Used to detect orphans: an endpoint that previously
// participated in a relationship (so it appears in the relationships table) but is no
// longer referenced by any active relationship. Bounded.
const SQL_HEALTH_ENDPOINT_PARTICIPATION = `
SELECT kind, id, SUM(active_count) AS active_total, SUM(any_count) AS any_total FROM (
  SELECT source_kind AS kind, source_id AS id,
         CASE WHEN lifecycle IN ('draft','active','archived') THEN 1 ELSE 0 END AS active_count,
         1 AS any_count
  FROM relationships WHERE workspace_scope_id = ?
  UNION ALL
  SELECT target_kind AS kind, target_id AS id,
         CASE WHEN lifecycle IN ('draft','active','archived') THEN 1 ELSE 0 END AS active_count,
         1 AS any_count
  FROM relationships WHERE workspace_scope_id = ?
)
GROUP BY kind, id
ORDER BY kind ASC, id ASC
LIMIT ?
`;
// ─── Public mutating + reading API ────────────────────────────────────────────
export function insertRelationship(db, rel) {
    // Validator runs at the API layer before this is reached (storage.md §4); the store is the
    // structural barrier. CHECK constraints + the partial unique indexes catch the rest.
    const scopeCoordinate = relationshipScopeCoordinate(rel.scope);
    try {
        db.prepare(SQL_INSERT).run(rel.id, RELATIONSHIP_SCHEMA_VERSION, rel.workspaceId, rel.scope.kind, scopeCoordinate, rel.type, rel.source.kind, rel.source.id, rel.target.kind, rel.target.id, rel.lifecycleState, rel.createdAt, rel.updatedAt, rel.etag, rel.confidence ?? null, rel.summary ?? null);
    }
    catch (error) {
        if (error instanceof Error && /UNIQUE/i.test(error.message)) {
            throw new UiStoreError("invalid_request", "Cardinality constraint violated.", 409);
        }
        throw error;
    }
    // History row for the initial state (draft → active is one common case; for any other
    // initial lifecycle the row records draft → <initial> per lifecycle.md §3). The PK pairs the
    // relationship id with a `randomUUID` suffix so re-creation under a recycled id (or two events
    // arriving at the same `Date.now()` millisecond) cannot collide on the history PK.
    insertHistoryRow(db, {
        id: nextHistoryRowId(rel.id),
        relationshipId: rel.id,
        fromState: "draft",
        toState: rel.lifecycleState,
        occurredAt: rel.createdAt,
        summary: rel.summary,
    });
    const row = db.prepare(SQL_GET).get(rel.id, rel.workspaceId);
    if (row === undefined) {
        throw new UiStoreError("internal", "Insert returned no row.", 500);
    }
    return rowToRelationship(row);
}
export function getRelationship(db, id, workspaceId) {
    const row = db.prepare(SQL_GET).get(id, workspaceId);
    return row === undefined ? undefined : rowToRelationship(row);
}
export function getRelationshipEtag(db, id, workspaceId) {
    const row = db.prepare(SQL_GET_ETAG_SCOPED).get(id, workspaceId);
    return row?.etag;
}
export function updateRelationshipLifecycle(db, args) {
    const info = db
        .prepare(SQL_UPDATE_LIFECYCLE)
        .run(args.to, args.updatedAt, args.newEtag, args.summary ?? null, args.id, args.workspaceId);
    if (info.changes === 0)
        throw notFound("Relationship");
    // Issue #539 audit: a deterministic `${id}-h-${updatedAt}` PK collides when two transitions
    // share a `Date.now()` millisecond (frequent in tests with a pinned clock; possible in
    // production at fast clock ticks). UNIQUE-violation would roll back the entire UPDATE — a
    // silent lifecycle revert. The randomUUID-suffixed PK below stays unique within a workspace.
    insertHistoryRow(db, {
        id: nextHistoryRowId(args.id),
        relationshipId: args.id,
        fromState: args.previous,
        toState: args.to,
        occurredAt: args.updatedAt,
        summary: args.summary,
    });
    const row = db.prepare(SQL_GET).get(args.id, args.workspaceId);
    if (row === undefined)
        throw notFound("Relationship");
    return rowToRelationship(row);
}
export function reconnectRelationship(db, args) {
    const info = db
        .prepare(SQL_UPDATE_RECONNECT)
        .run(args.target.kind, args.target.id, args.updatedAt, args.newEtag, args.summary ?? null, args.id, args.workspaceId);
    if (info.changes === 0)
        throw notFound("Relationship");
    const row = db.prepare(SQL_GET).get(args.id, args.workspaceId);
    if (row === undefined)
        throw notFound("Relationship");
    return rowToRelationship(row);
}
export function relationshipCardinalitySnapshot(db, workspaceId, source, target) {
    const sourceCount = db.prepare(SQL_COUNT_PRODUCES_EVIDENCE_FOR_SOURCE).get(workspaceId, source.kind, source.id).n;
    const targetCount = db.prepare(SQL_COUNT_STARTS_WORKFLOW_FOR_TARGET).get(workspaceId, target.kind, target.id).n;
    return {
        producesEvidenceForSource: sourceCount,
        startsWorkflowForTarget: targetCount,
    };
}
// Closed-set predicate fragments. Each fragment is a STATIC `column = ?` string;
// only the parameter values are bound at execution time. No user input enters SQL text.
const LIST_FILTER_FRAGMENTS = [
    { key: "sourceKind", clause: "source_kind = ?" },
    { key: "sourceId", clause: "source_id = ?" },
    { key: "targetKind", clause: "target_kind = ?" },
    { key: "targetId", clause: "target_id = ?" },
    { key: "type", clause: "type = ?" },
    { key: "lifecycle", clause: "lifecycle = ?" },
    { key: "afterEtag", clause: "etag < ?" },
];
function buildListClauses(q) {
    const clauses = ["workspace_scope_id = ?"];
    const params = [q.workspaceId];
    for (const fragment of LIST_FILTER_FRAGMENTS) {
        const value = q[fragment.key];
        if (value !== undefined) {
            clauses.push(fragment.clause);
            params.push(value);
        }
    }
    return { clauses, params };
}
export function listRelationships(db, q) {
    if (q.limit <= 0 || q.limit > MAX_LIST_LIMIT) {
        throw invalidRequest("Limit out of bounds.");
    }
    const { clauses, params: filterParams } = buildListClauses(q);
    const params = [...filterParams, q.limit + 1];
    const sql = "SELECT id, schema_version, workspace_scope_id, scope_kind, scope_coordinate, type," +
        " source_kind, source_id, target_kind, target_id, lifecycle, created_at, updated_at," +
        " etag, confidence, summary FROM relationships WHERE " +
        clauses.join(" AND ") +
        " ORDER BY etag DESC, id ASC LIMIT ?";
    const rows = db.prepare(sql).all(...params);
    const truncated = rows.length > q.limit;
    const slice = truncated ? rows.slice(0, q.limit) : rows;
    const entries = slice.map(rowToRelationship);
    const last = slice[slice.length - 1];
    const nextCursor = truncated && last !== undefined ? last.etag : undefined;
    // Always return undefined when not truncated; `nextCursor` is `string | undefined` so
    // exactOptionalPropertyTypes-safe.
    return nextCursor === undefined
        ? { entries, truncated, nextCursor: undefined }
        : { entries, truncated, nextCursor };
}
export function findRelationshipsBySource(db, workspaceId, source, limit) {
    if (limit <= 0 || limit > MAX_LIST_LIMIT)
        throw invalidRequest("Limit out of bounds.");
    const rows = db
        .prepare(SQL_FIND_BY_SOURCE)
        .all(workspaceId, source.kind, source.id, limit);
    return rows.map(rowToRelationship);
}
export function findRelationshipsByTarget(db, workspaceId, target, limit) {
    if (limit <= 0 || limit > MAX_LIST_LIMIT)
        throw invalidRequest("Limit out of bounds.");
    const rows = db
        .prepare(SQL_FIND_BY_TARGET)
        .all(workspaceId, target.kind, target.id, limit);
    return rows.map(rowToRelationship);
}
export function listRelationshipLifecycleHistory(db, relationshipId, limit = LIFECYCLE_HISTORY_RETAIN) {
    if (limit <= 0 || limit > LIFECYCLE_HISTORY_RETAIN) {
        throw invalidRequest("History limit out of bounds.");
    }
    const rows = db.prepare(SQL_LIST_HISTORY).all(relationshipId, limit);
    return rows.map((r) => {
        const base = {
            relationshipId: r.relationship_id,
            fromState: r.from_state,
            toState: r.to_state,
            occurredAt: r.occurred_at,
            ...(r.summary === null ? {} : { summary: r.summary }),
        };
        return base;
    });
}
export function walkDependencies(db, options) {
    validateWalkBounds(options);
    const origin = getRelationship(db, options.originId, options.workspaceId);
    if (origin === undefined)
        throw notFound("Relationship");
    return runWalkFromOrigin(db, origin, options);
}
export function computeImpact(db, options) {
    validateWalkBounds(options);
    return runWalk(db, options.workspaceId, [options.endpoint], options);
}
export function graphHealth(db, workspaceId) {
    const totals = computeLifecycleTotals(db, workspaceId);
    const findings = computeHealthFindings(db, workspaceId);
    const truncated = findings.orphanedEndpointsTruncated ||
        findings.staleRelationshipsTruncated ||
        findings.blockedRelationshipsTruncated ||
        findings.failedRelationshipsTruncated ||
        findings.invalidReferencesTruncated ||
        findings.cycleScanTruncated;
    return { checkedAt: Date.now(), totals, truncated, findings };
}
function computeLifecycleTotals(db, workspaceId) {
    const rows = db.prepare(SQL_HEALTH_COUNTS).all(workspaceId);
    const totals = {
        draft: 0,
        active: 0,
        archived: 0,
        superseded: 0,
        revoked: 0,
        blocked: 0,
        stale: 0,
    };
    for (const r of rows) {
        if (r.lifecycle in totals) {
            totals[r.lifecycle] = r.n;
        }
    }
    return totals;
}
function rawRowToRef(r) {
    return {
        id: r.id,
        type: r.type,
        source: { kind: r.source_kind, id: r.source_id },
        target: { kind: r.target_kind, id: r.target_id },
        lifecycle: r.lifecycle,
    };
}
function selectFindingsByLifecycle(db, workspaceId, lifecycle) {
    // Over-fetch by one to detect truncation without a separate COUNT round-trip.
    const cap = MAX_RELATIONSHIPS_PER_QUERY;
    const raw = db
        .prepare(SQL_HEALTH_FINDINGS_BY_LIFECYCLE)
        .all(workspaceId, lifecycle, cap + 1);
    const truncated = raw.length > cap;
    const sliced = truncated ? raw.slice(0, cap) : raw;
    return { rows: sliced.map(rawRowToRef), truncated };
}
const SUPPORTED_OBJECT_KIND_SET = new Set(RELATIONSHIP_SUPPORTED_OBJECT_KINDS);
function selectInvalidReferences(db, workspaceId) {
    const cap = MAX_RELATIONSHIPS_PER_QUERY;
    // Scan up to cap+1 rows; an endpoint kind not in the supported set marks the
    // relationship as referencing an unsupported object kind.
    const raw = db
        .prepare(SQL_HEALTH_ACTIVE_RELATIONSHIPS)
        .all(workspaceId, cap + 1);
    const scanTruncated = raw.length > cap;
    const sliced = scanTruncated ? raw.slice(0, cap) : raw;
    const out = [];
    for (const row of sliced) {
        if (!SUPPORTED_OBJECT_KIND_SET.has(row.source_kind) ||
            !SUPPORTED_OBJECT_KIND_SET.has(row.target_kind)) {
            out.push(rawRowToRef(row));
        }
    }
    return { rows: out, truncated: scanTruncated };
}
function selectCycleParticipants(db, workspaceId) {
    const cap = MAX_RELATIONSHIPS_PER_QUERY;
    const raw = db
        .prepare(SQL_HEALTH_ACTIVE_RELATIONSHIPS)
        .all(workspaceId, cap + 1);
    const scanTruncated = raw.length > cap;
    const sliced = scanTruncated ? raw.slice(0, cap) : raw;
    // Build adjacency keyed by endpoint, then DFS to find back-edges. Detection is
    // O(V + E) over the bounded scan input.
    const refs = sliced.map(rawRowToRef);
    const participantIds = detectCycleParticipantIds(refs);
    const rows = refs.filter((r) => participantIds.has(r.id));
    return { rows, scanTruncated };
}
function detectCycleParticipantIds(refs) {
    // Adjacency: node-key -> list of {nextNodeKey, relId}. Self-loops are cycles too.
    const adjacency = new Map();
    for (const ref of refs) {
        const fromKey = nodeKey(ref.source);
        const toKey = nodeKey(ref.target);
        const list = adjacency.get(fromKey) ?? [];
        list.push({ nextKey: toKey, relId: ref.id });
        adjacency.set(fromKey, list);
    }
    const participants = new Set();
    const state = new Map();
    // Iterative DFS to avoid stack blow-up on deep graphs.
    for (const startKey of adjacency.keys()) {
        if (state.get(startKey) !== undefined)
            continue;
        dfsFromStart(startKey, adjacency, state, participants);
    }
    return participants;
}
function dfsFromStart(startKey, adjacency, state, participants) {
    const stack = [{ key: startKey, index: 0, viaRelId: null }];
    state.set(startKey, "visiting");
    while (stack.length > 0) {
        const frame = stack[stack.length - 1];
        if (frame === undefined)
            break;
        const neighbours = adjacency.get(frame.key) ?? [];
        if (frame.index >= neighbours.length) {
            state.set(frame.key, "done");
            stack.pop();
            continue;
        }
        const edge = neighbours[frame.index];
        frame.index += 1;
        if (edge === undefined)
            continue;
        const neighbourState = state.get(edge.nextKey);
        if (neighbourState === "visiting") {
            // Back-edge: every relationship on the current path from edge.nextKey down
            // is a cycle participant.
            markBackEdgeParticipants(stack, edge, participants);
            continue;
        }
        if (neighbourState === "done")
            continue;
        state.set(edge.nextKey, "visiting");
        stack.push({ key: edge.nextKey, index: 0, viaRelId: edge.relId });
    }
}
function markBackEdgeParticipants(stack, closingEdge, participants) {
    // Walk the stack from top down to the frame whose key matches closingEdge.nextKey,
    // marking the relationship that brought us into each frame as a participant.
    participants.add(closingEdge.relId);
    for (let i = stack.length - 1; i >= 0; i--) {
        const frame = stack[i];
        if (frame === undefined)
            break;
        if (frame.viaRelId !== null)
            participants.add(frame.viaRelId);
        if (frame.key === closingEdge.nextKey)
            break;
    }
}
function selectOrphanedEndpoints(db, workspaceId) {
    const cap = MAX_RELATIONSHIPS_PER_QUERY;
    const raw = db
        .prepare(SQL_HEALTH_ENDPOINT_PARTICIPATION)
        .all(workspaceId, workspaceId, cap + 1);
    const truncated = raw.length > cap;
    const sliced = truncated ? raw.slice(0, cap) : raw;
    const out = [];
    for (const r of sliced) {
        if (r.active_total === 0 && r.any_total > 0) {
            out.push({ kind: r.kind, id: r.id });
        }
    }
    return { rows: out, truncated };
}
function computeHealthFindings(db, workspaceId) {
    const stale = selectFindingsByLifecycle(db, workspaceId, "stale");
    const blocked = selectFindingsByLifecycle(db, workspaceId, "blocked");
    // `failedRelationships` is the public field name on the health response; the underlying query
    // selects `lifecycle='revoked'` rows (the lifecycle.md "failure" terminal state). The field
    // alias is intentional — the public health surface speaks "failed" / the lifecycle state is
    // `revoked`.
    const failed = selectFindingsByLifecycle(db, workspaceId, "revoked");
    const invalid = selectInvalidReferences(db, workspaceId);
    const cycle = selectCycleParticipants(db, workspaceId);
    const orphan = selectOrphanedEndpoints(db, workspaceId);
    return {
        orphanedEndpoints: orphan.rows,
        orphanedEndpointsTruncated: orphan.truncated,
        staleRelationships: stale.rows,
        staleRelationshipsTruncated: stale.truncated,
        blockedRelationships: blocked.rows,
        blockedRelationshipsTruncated: blocked.truncated,
        failedRelationships: failed.rows,
        failedRelationshipsTruncated: failed.truncated,
        invalidReferences: invalid.rows,
        invalidReferencesTruncated: invalid.truncated,
        cycleParticipants: cycle.rows,
        cycleScanTruncated: cycle.scanTruncated,
    };
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
function relationshipScopeCoordinate(scope) {
    switch (scope.kind) {
        case "user":
            return scope.userId;
        case "workspace":
            return scope.workspaceId;
        case "project":
            return scope.projectId;
        case "workflow":
            return scope.workflowDefinitionId;
        case "global":
            return "global";
    }
}
function insertHistoryRow(db, row) {
    db.prepare(SQL_INSERT_HISTORY).run(row.id, row.relationshipId, row.fromState, row.toState, row.occurredAt, row.summary ?? null);
}
// Stable history-row PK builder: `<relationshipId>-h-<8 hex chars>`. The relationship id is the
// owning row; the random suffix avoids same-millisecond collisions that a deterministic clock
// (test fixtures, fast production clock ticks) would otherwise produce.
function nextHistoryRowId(relationshipId) {
    return `${relationshipId}-h-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
}
function validateWalkBounds(o) {
    if (o.maxDepth <= 0 || o.maxDepth > MAX_IMPACT_DEPTH) {
        throw invalidRequest("maxDepth out of bounds.");
    }
    if (o.maxNodes <= 0 || o.maxNodes > MAX_IMPACT_NODES) {
        throw invalidRequest("maxNodes out of bounds.");
    }
    if (o.maxRelationships <= 0 || o.maxRelationships > MAX_IMPACT_RELATIONSHIPS) {
        throw invalidRequest("maxRelationships out of bounds.");
    }
}
function runWalkFromOrigin(db, origin, options) {
    // Seed from the relationship's endpoints — the walk includes the origin row itself plus
    // its neighbours, expanding by `direction` per hop.
    const seedEndpoints = [];
    if (options.direction !== "incoming") {
        seedEndpoints.push({ kind: origin.target.kind, id: origin.target.id });
    }
    if (options.direction !== "outgoing") {
        seedEndpoints.push({ kind: origin.source.kind, id: origin.source.id });
    }
    const walkResult = runWalk(db, options.workspaceId, seedEndpoints, {
        direction: options.direction,
        maxDepth: options.maxDepth,
        maxNodes: options.maxNodes,
        maxRelationships: options.maxRelationships,
    });
    // Always include the origin relationship + endpoints.
    const seenRels = new Set(walkResult.relationships.map((r) => r.id));
    const relationships = seenRels.has(origin.id)
        ? [...walkResult.relationships]
        : [origin, ...walkResult.relationships];
    const seenNodes = new Set(walkResult.nodes.map(nodeKey));
    const nodes = [...walkResult.nodes];
    for (const n of [origin.source, origin.target]) {
        if (!seenNodes.has(nodeKey(n))) {
            seenNodes.add(nodeKey(n));
            nodes.push({ kind: n.kind, id: n.id });
        }
    }
    return {
        relationships,
        nodes,
        truncated: walkResult.truncated,
        truncationReason: walkResult.truncationReason,
        depthReached: walkResult.depthReached,
    };
}
function nodeKey(n) {
    return `${n.kind}/${n.id}`;
}
function admitEndpoint(state, endpoint, nextFrontier) {
    const key = nodeKey(endpoint);
    if (state.visitedNodes.has(key))
        return true;
    if (state.collectedNodes.length >= state.maxNodes) {
        state.truncationReason = "max-nodes";
        return false;
    }
    state.visitedNodes.add(key);
    const admitted = { kind: endpoint.kind, id: endpoint.id };
    state.collectedNodes.push(admitted);
    nextFrontier.push(admitted);
    return true;
}
function admitRelationship(state, rel) {
    if (state.seenRelationships.has(rel.id))
        return true;
    if (state.seenRelationships.size >= state.maxRelationships) {
        state.truncationReason = "max-relationships";
        return false;
    }
    state.seenRelationships.set(rel.id, rel);
    return true;
}
function expandFrontier(outgoingStatement, incomingStatement, workspaceId, frontier, direction, state) {
    const nextFrontier = [];
    for (const node of frontier) {
        const neighbours = expandNeighbours(outgoingStatement, incomingStatement, workspaceId, node, direction);
        for (const rel of neighbours) {
            if (!admitRelationship(state, rel))
                return nextFrontier;
            if (!admitEndpoint(state, rel.source, nextFrontier))
                return nextFrontier;
            if (!admitEndpoint(state, rel.target, nextFrontier))
                return nextFrontier;
        }
    }
    return nextFrontier;
}
function seedWalkState(seed, options) {
    const state = {
        visitedNodes: new Set(),
        collectedNodes: [],
        seenRelationships: new Map(),
        maxNodes: options.maxNodes,
        maxRelationships: options.maxRelationships,
        truncationReason: null,
    };
    for (const s of seed) {
        if (!state.visitedNodes.has(nodeKey(s))) {
            state.visitedNodes.add(nodeKey(s));
            state.collectedNodes.push({ kind: s.kind, id: s.id });
        }
    }
    return state;
}
function walkResult(state, depthReached, truncationReason) {
    return {
        relationships: [...state.seenRelationships.values()],
        nodes: state.collectedNodes,
        truncated: truncationReason !== null,
        truncationReason,
        depthReached,
    };
}
function runWalk(db, workspaceId, seed, options) {
    const state = seedWalkState(seed, options);
    const outgoingStatement = options.direction === "incoming" ? undefined : db.prepare(SQL_FIND_BY_SOURCE);
    const incomingStatement = options.direction === "outgoing" ? undefined : db.prepare(SQL_FIND_BY_TARGET);
    let depthReached = 0;
    let frontier = [...state.collectedNodes];
    for (let depth = 0; depth < options.maxDepth; depth++) {
        depthReached = depth + 1;
        const nextFrontier = expandFrontier(outgoingStatement, incomingStatement, workspaceId, frontier, options.direction, state);
        if (state.truncationReason !== null) {
            return walkResult(state, depthReached, state.truncationReason);
        }
        if (nextFrontier.length === 0)
            break;
        frontier = nextFrontier;
    }
    // depth-bounded normal completion is not a truncation per se; we only mark `max-depth`
    // when the frontier was non-empty after the last hop (more work to do).
    const exhaustedDepth = frontier.length > 0 && depthReached === options.maxDepth;
    return walkResult(state, depthReached, exhaustedDepth ? "max-depth" : null);
}
function expandNeighbours(outgoingStatement, incomingStatement, workspaceId, node, direction) {
    const out = [];
    if (direction !== "incoming") {
        const rows = outgoingStatement?.all(workspaceId, node.kind, node.id, MAX_LIST_LIMIT);
        for (const row of rows ?? [])
            out.push(rowToRelationship(row));
    }
    if (direction !== "outgoing") {
        const rows = incomingStatement?.all(workspaceId, node.kind, node.id, MAX_LIST_LIMIT);
        for (const row of rows ?? [])
            out.push(rowToRelationship(row));
    }
    return out;
}
