/** * 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; const bObj = b as Record; 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, 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)[part]; } return current; } /** * Flattens a nested object into dot-notation paths with their values. * Only flattens plain objects, not arrays. */ function flattenPaths( obj: Record, 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, 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 ): Conflict[] { const conflicts: Conflict[] = []; const existingObj = existing as unknown as Record; 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; }