Files
Orchestrator/privat/CV/src/interview/conflict-detection.ts
T
ankn a5f8fb49ab Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
2026-06-30 20:39:52 +02:00

123 lines
3.3 KiB
TypeScript

/**
* Conflict Detection — identifies fields where a proposed update
* differs from the existing entity value.
*
* Pure function, no I/O.
*/
import type { Entity } from '../schemas/types';
// --- Types ---
export interface Conflict {
/** Dot-notation path to the conflicting field */
fieldPath: string;
/** The current value in the existing entity */
existingValue: unknown;
/** The proposed new value */
proposedValue: unknown;
}
// --- Helpers ---
/**
* Deep equality check for JSON-serializable values.
*/
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || b === null) return false;
if (a === undefined || b === undefined) return false;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, i) => deepEqual(item, b[i]));
}
if (typeof a === 'object' && typeof b === 'object') {
const aObj = a as Record<string, unknown>;
const bObj = b as Record<string, unknown>;
const aKeys = Object.keys(aObj);
const bKeys = Object.keys(bObj);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
}
return false;
}
/**
* Resolves a dot-notation field path on an object.
*/
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const parts = path.split('.');
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined || typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[part];
}
return current;
}
/**
* Flattens a nested object into dot-notation paths with their values.
* Only flattens plain objects, not arrays.
*/
function flattenPaths(
obj: Record<string, unknown>,
prefix = ''
): Array<{ path: string; value: unknown }> {
const results: Array<{ path: string; value: unknown }> = [];
for (const key of Object.keys(obj)) {
const fullPath = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
if (
value !== null &&
value !== undefined &&
typeof value === 'object' &&
!Array.isArray(value)
) {
results.push(...flattenPaths(value as Record<string, unknown>, fullPath));
} else {
results.push({ path: fullPath, value });
}
}
return results;
}
// --- Main Function ---
/**
* Detects conflicts between an existing entity and a proposed update.
* A conflict exists when the proposed value for a field differs from the existing value
* and the existing value is already set (not undefined/null).
*/
export function detectConflicts(
existing: Entity,
proposed: Record<string, unknown>
): Conflict[] {
const conflicts: Conflict[] = [];
const existingObj = existing as unknown as Record<string, unknown>;
const flatProposed = flattenPaths(proposed);
for (const { path, value: proposedValue } of flatProposed) {
const existingValue = getNestedValue(existingObj, path);
// Only flag as conflict if existing value is set and differs
if (existingValue !== undefined && existingValue !== null && !deepEqual(existingValue, proposedValue)) {
conflicts.push({
fieldPath: path,
existingValue,
proposedValue,
});
}
}
return conflicts;
}