/** * @factory/database-engine/relations — link-set diffing + cardinality policy * (TASK-0081, pure half). * * `diffLinks` computes the {added, removed} of an absolute desired link set * vs the current set (the API's transaction does delete-then-insert). * `applyCardinality` enforces the cardinality policy (database-architecture * §1.4: "cardinality as policy, not constraint"): 1:1 replacement semantics * (a second target on the "one" side unlinks the previous — the replaced * ids are returned), 1:N single-side assertions. The join table is uniform * M:N; these functions decide what the transaction writes. */ export type Cardinality = "ONE_TO_ONE" | "ONE_TO_MANY" | "MANY_TO_MANY"; export interface LinkDiff { added: string[]; removed: string[]; unchanged: string[]; } /** * Diff an absolute desired set against the current links. * `current` = record's existing target ids; `desired` = the full desired set * (the client sends absolute, not delta — TASK-0081 R1). */ export function diffLinks(current: readonly string[], desired: readonly string[]): LinkDiff { const cur = new Set(current); const des = new Set(desired); const added: string[] = []; const removed: string[] = []; const unchanged: string[] = []; for (const id of desired) { if (cur.has(id)) unchanged.push(id); else added.push(id); } for (const id of current) { if (!des.has(id)) removed.push(id); } return { added, removed, unchanged }; } export interface CardinalityCheck { ok: boolean; /** Target ids to unlink (1:1 replacement). */ replaced: string[]; error?: string; } /** * Validate a desired link set against the relation's cardinality. * `isOneSide` = whether this record sits on the exclusive ("one") side. * - ONE_TO_ONE: the "one" side holds at most 1 target; a 2nd replaces the * first (`replaced` = the previous id, returned to the API for its * response). Both sides exclusive — the caller also checks the target's * existing partner (bidirectional replacement). * - ONE_TO_MANY: the "one" side holds exactly ≤ 1 target (>1 → 400); the * "many" side is unlimited. * - MANY_TO_MANY: unlimited. * Target ids are deduped (a repeated id in the desired set is not an error). */ export function applyCardinality( current: readonly string[], desired: readonly string[], cardinality: Cardinality, isOneSide: boolean, ): CardinalityCheck { const deduped = [...new Set(desired)]; if (cardinality === "MANY_TO_MANY") return { ok: true, replaced: [] }; if (isOneSide) { if (deduped.length > 1) { return { ok: false, replaced: [], error: "one_side_exceeds_one_target" }; } // 1:1 — replacing the previous link is allowed const replaced = current.filter((id) => !deduped.includes(id)); return { ok: true, replaced }; } // many side of 1:N — unlimited targets return { ok: true, replaced: [] }; } /** * Compute the full transaction plan for `setLinks`: the join-table rows to * delete and insert, plus (1:1) the displaced partner on the target side. * `targetPartners` = map of target id → the record currently partnered with * it (for bidirectional 1:1 replacement). */ export interface SetLinksPlan { deleteIds: string[]; insertIds: string[]; /** 1:1: records on the OTHER side displaced by this set. */ displacedPartners: Array<{ targetId: string; partnerRecordId: string }>; replaced: string[]; } export function planSetLinks( current: readonly string[], desired: readonly string[], cardinality: Cardinality, isOneSide: boolean, targetPartners: ReadonlyMap, ): SetLinksPlan { const check = applyCardinality(current, desired, cardinality, isOneSide); if (!check.ok) throw new Error(check.error ?? "cardinality violation"); const { added, removed } = diffLinks(current, desired); const deduped = [...new Set(desired)]; const displacedPartners: Array<{ targetId: string; partnerRecordId: string }> = []; if (cardinality === "ONE_TO_ONE") { for (const targetId of deduped) { const partner = targetPartners.get(targetId); if (partner !== undefined) displacedPartners.push({ targetId, partnerRecordId: partner }); } } return { deleteIds: removed, insertIds: added, displacedPartners, replaced: check.replaced }; } /** Cascade: record trash → its links removed (both directions by row shape). */ export function linksForRecord( links: readonly { databaseId: string; recordId: string; targetDatabaseId: string; targetRecordId: string; }[], recordId: string, ): string[] { const out = new Set(); for (const l of links) { if (l.recordId === recordId) out.add(l.targetRecordId); } return [...out]; } /** Relation counts for the query response (TASK-0081 R2 `relationCounts`). */ export function relationCounts( links: readonly { recordId: string; targetRecordId: string }[], recordIds: readonly string[], ): Record { const counts: Record = {}; for (const id of recordIds) counts[id] = 0; for (const l of links) { if (counts[l.recordId] !== undefined) counts[l.recordId] = (counts[l.recordId] ?? 0) + 1; } return counts; }