// @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] };
};