Source: Router/maintenance/memberALinkDecision.js

// @ts-check

/**
 * The decision which `memberA` link of one object is the intended one.
 *
 * Deliberately pure and dependency-free: the repair endpoint feeds it rows straight
 * from the database, and the unit test feeds it hand-built objects. In particular the
 * `ValidFrom` tie can only be asserted this way — `ValidFrom` is a generated
 * `ROW START`, so two links with an identical start cannot be produced through the
 * application.
 *
 * The object under decision has the shape built by `groupBySource()`:
 *
 * ```js
 * {
 *   UID: Buffer, type: string|null, orphaned: boolean,
 *   links: [{ targetUID, targetType, targetStage, targetHierarchie,
 *             validFrom: Date|string, hasDynamic: boolean, corroborated: boolean,
 *             dangling: boolean }]
 * }
 *
 * `corroborated` means the source object also points at that target through another
 * link type (`member`, `memberS`, `memberG`, `memberGA`, `function`) — see the tie
 * break below. It is filled by `groupBySource()` from the report query.
 * ```
 *
 * Reporting (which links to list for a human) is the caller's concern; this module
 * only answers "keep which, remove which, or ask a human".
 *
 * @import {Buffer} from 'node:buffer'
 */

/**
 * `ValidFrom` is a `timestamp(6)`; the driver hands it over as a `Date`. The
 * comparison has to use the microseconds, not the rendered date.
 *
 * @param {unknown} value
 * @returns {number}
 */
const timeOf = (value) => (value instanceof Date ? value.getTime() : new Date(/** @type {string} */ (value)).getTime());

/**
 * @typedef {Object} LinkDecision
 * @property {any[]} keep       - the links that survive (at most one, none on a skip)
 * @property {any[]} remove     - the links to delete
 * @property {string|null} skipReason - set when a human has to decide
 */

/**
 * @param {any} source
 * @returns {LinkDecision}
 */
export const decideMemberALinks = (source) => {
    /** @type {LinkDecision} */
    const empty = { keep: [], remove: [], skipReason: null };

    if (source.orphaned) {
        // The object itself is deleted, so every one of its `memberA` links is debris:
        // it belongs to no live object any more and can never satisfy the invariant for
        // one. All of them go.
        //
        // `restorePerson` is unaffected: it reads links from the system history
        // (`FOR SYSTEM_TIME AS OF`) and inserts only those missing today, while the
        // removal keeps that history intact.
        return { ...empty, remove: source.links };
    }

    // --- dangling targets -------------------------------------------------
    // A link to a deleted object can never satisfy the invariant meaningfully, but it
    // is only removable while a live link remains to take over. It never takes part in
    // the "which one is the intention" decision.
    const dangling = source.links.filter((/** @type {any} */ l) => l.dangling);
    const alive = source.links.filter((/** @type {any} */ l) => !l.dangling);

    if (alive.length === 0) {
        // Every link points at a deleted target, so there is no valid link to keep and
        // the invariant is unreachable for this object. Removing the links would leave
        // it with none at all — a change of behaviour — and, worse, would make it
        // INVISIBLE: the report walks `Links`, so an object without links never shows up
        // again. Leave the object as it is and let a human decide. It is already broken
        // today, so nothing gets worse by waiting.
        return { ...empty, skipReason: 'every memberA link points at a deleted target' };
    }

    if (alive.length === 1) {
        // Nothing to choose between. Only the dead links go, and the report still
        // names the link that survives — that is the part a human wants to see.
        return { ...empty, keep: dangling.length > 0 ? alive : [], remove: dangling };
    }

    // --- entry: the dynamic list reference decides ------------------------
    if (source.type === 'entry') {
        const corroborated = alive.filter((/** @type {any} */ l) => l.hasDynamic);
        if (corroborated.length === 1) {
            return {
                ...empty,
                keep: corroborated,
                remove: [...alive.filter((/** @type {any} */ l) => l !== corroborated[0]), ...dangling]
            };
        }
        if (corroborated.length > 1) {
            // The entry really spans several lists — it must be split into one `entry`
            // per list, which only the list rebuild can do. Never guess here.
            return {
                ...empty,
                skipReason: `entry corroborated by ${corroborated.length} dynamic links — needs to be split`
            };
        }
        // No dynamic reference at all: fall through to "newest wins" below.
    }

    // --- newest `ValidFrom` wins -----------------------------------------
    const sorted = [...alive].sort(
        (/** @type {any} */ a, /** @type {any} */ b) => timeOf(b.validFrom) - timeOf(a.validFrom)
    );
    const newest = sorted[0];
    const newestTime = timeOf(newest.validFrom);
    const tied = sorted.filter((/** @type {any} */ l) => timeOf(l.validFrom) === newestTime);
    const rest = sorted.filter((/** @type {any} */ l) => timeOf(l.validFrom) !== newestTime);

    if (tied.length > 1) {
        // The timestamp cannot decide. Before asking a human, consult the secondary
        // evidence: does the object point at that target through ANOTHER link type as
        // well? A `member` link next to the `memberA` link means "really belongs there"
        // and breaks the tie without guessing.
        //
        // Production reference: an `extern` carries two `memberA` links started at the
        // very same imported system period (2024-11-13 00:00:00, 3 of 416,960 active
        // links share it). Only one of the two targets — `Stamm Jungen` — is additionally
        // held via `member`, so that is the intended one.
        const corroborated = tied.filter((/** @type {any} */ l) => l.corroborated);
        if (corroborated.length === 1) {
            return {
                ...empty,
                keep: corroborated,
                remove: [
                    ...tied.filter((/** @type {any} */ l) => l !== corroborated[0]),
                    ...rest,
                    ...dangling
                ]
            };
        }
        // Several corroborated links, or none at all: there is no answer — a human has
        // to look. Never guess.
        return {
            ...empty,
            skipReason: corroborated.length > 1
                ? 'active memberA links share the same ValidFrom and several are corroborated'
                : 'active memberA links share the same ValidFrom'
        };
    }

    return { ...empty, keep: [newest], remove: [...rest, ...dangling] };
};