@ng-forge/dynamic-forms 1.0.0-rc.2 → 1.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. package/fesm2022/{ng-forge-dynamic-forms-array-field.component-8rodySeE.mjs → ng-forge-dynamic-forms-array-field.component-D5qCzJsL.mjs} +2 -2
  2. package/fesm2022/{ng-forge-dynamic-forms-array-field.component-8rodySeE.mjs.map → ng-forge-dynamic-forms-array-field.component-D5qCzJsL.mjs.map} +1 -1
  3. package/fesm2022/{ng-forge-dynamic-forms-bootstrap-derivation-orchestrator-C8kpF0tw.mjs → ng-forge-dynamic-forms-bootstrap-derivation-orchestrator-D6ntLtW9.mjs} +2 -2
  4. package/fesm2022/{ng-forge-dynamic-forms-bootstrap-derivation-orchestrator-C8kpF0tw.mjs.map → ng-forge-dynamic-forms-bootstrap-derivation-orchestrator-D6ntLtW9.mjs.map} +1 -1
  5. package/fesm2022/{ng-forge-dynamic-forms-container-field.component-DFgjWEZN.mjs → ng-forge-dynamic-forms-container-field.component-nB6KFqVs.mjs} +2 -2
  6. package/fesm2022/{ng-forge-dynamic-forms-container-field.component-DFgjWEZN.mjs.map → ng-forge-dynamic-forms-container-field.component-nB6KFqVs.mjs.map} +1 -1
  7. package/fesm2022/{ng-forge-dynamic-forms-group-field.component-Ca4AqdZu.mjs → ng-forge-dynamic-forms-group-field.component-Bq6Sgd6Z.mjs} +2 -2
  8. package/fesm2022/{ng-forge-dynamic-forms-group-field.component-Ca4AqdZu.mjs.map → ng-forge-dynamic-forms-group-field.component-Bq6Sgd6Z.mjs.map} +1 -1
  9. package/fesm2022/{ng-forge-dynamic-forms-ng-forge-dynamic-forms-BJi_rtey.mjs → ng-forge-dynamic-forms-ng-forge-dynamic-forms-C59pvc26.mjs} +10 -10
  10. package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-C59pvc26.mjs.map +1 -0
  11. package/fesm2022/{ng-forge-dynamic-forms-page-field.component-IsAtMNjD.mjs → ng-forge-dynamic-forms-page-field.component-DRaxbiqA.mjs} +2 -2
  12. package/fesm2022/{ng-forge-dynamic-forms-page-field.component-IsAtMNjD.mjs.map → ng-forge-dynamic-forms-page-field.component-DRaxbiqA.mjs.map} +1 -1
  13. package/fesm2022/ng-forge-dynamic-forms.mjs +1 -1
  14. package/package.json +3 -3
  15. package/types/ng-forge-dynamic-forms-integration.d.ts +1 -5
  16. package/types/ng-forge-dynamic-forms-internal.d.ts +30 -7
  17. package/types/ng-forge-dynamic-forms.d.ts +5 -5
  18. package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-BJi_rtey.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"ng-forge-dynamic-forms-bootstrap-derivation-orchestrator-C8kpF0tw.mjs","mappings":"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","names":[],"ignoreList":[],"sources":["../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-logger.service.ts","../../../../packages/dynamic-forms/src/lib/utils/path-utils/path-utils.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-types.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/compute-derived-value.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/evaluation-scope.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-constants.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/field-value-utils.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-applicator.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-sorter.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/extract-dependencies.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/extract-inline-fn.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/collection-context.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-collector.ts","../../../../packages/dynamic-forms/src/lib/core/property-derivation/property-derivation-collector.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/cycle-detector.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/http-derivation-stream.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/async-derivation-stream.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/external-data-resolver.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/debounce-period-utils.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/entry-set-utils.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/pipeline-setup-utils.ts","../../../../packages/dynamic-forms/src/lib/core/property-derivation/property-derivation-types.ts","../../../../packages/dynamic-forms/src/lib/core/property-derivation/property-derivation-applicator.ts","../../../../packages/dynamic-forms/src/lib/core/property-derivation/http-property-derivation-stream.ts","../../../../packages/dynamic-forms/src/lib/core/property-derivation/async-property-derivation-stream.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-orchestrator.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/bootstrap-derivation-orchestrator.ts"],"sourcesContent":["import { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { DerivationLogConfig, createDefaultDerivationLogConfig, shouldLog } from '@ng-forge/dynamic-forms/internal';\nimport { DerivationProcessingResult } from './derivation-types';\nimport { DerivationLogEntry } from './derivation-logger';\n\n/** Service interface for derivation logging. */\nexport interface DerivationLogger {\n /**\n * Logs the start of a derivation processing cycle.\n * Only logs when config level is 'verbose'.\n */\n cycleStart(trigger: 'onChange' | 'debounced', entryCount: number): void;\n\n /**\n * Logs the start of a derivation iteration within a cycle.\n * Only logs when config level is 'verbose'.\n */\n iteration(iterationNumber: number): void;\n\n /**\n * Logs a single derivation evaluation result.\n * Only logs when config level is 'verbose'.\n */\n evaluation(entry: DerivationLogEntry): void;\n\n /**\n * Logs a summary of derivation processing results.\n * Logs when config level is 'summary' or 'verbose' and something interesting happened.\n */\n summary(result: DerivationProcessingResult, trigger: 'onChange' | 'debounced'): void;\n\n /** Logs when max iterations are reached (always logged as warning). */\n maxIterationsReached(result: DerivationProcessingResult, trigger: 'onChange' | 'debounced'): void;\n}\n\n/**\n * Active implementation of DerivationLogger that performs actual logging.\n *\n * @internal\n */\nclass ActiveDerivationLogger implements DerivationLogger {\n logger!: Logger;\n private config: DerivationLogConfig = createDefaultDerivationLogConfig();\n\n /** Updates the log configuration. */\n setConfig(config: DerivationLogConfig): void {\n this.config = config;\n }\n\n cycleStart(trigger: 'onChange' | 'debounced', entryCount: number): void {\n if (!shouldLog(this.config, 'verbose')) return;\n this.logger.debug(`Derivation - Starting cycle (${trigger}) with ${entryCount} derivation(s)`);\n }\n\n iteration(iterationNumber: number): void {\n if (!shouldLog(this.config, 'verbose')) return;\n this.logger.debug(`Derivation - Iteration ${iterationNumber}`);\n }\n\n evaluation(entry: DerivationLogEntry): void {\n if (!shouldLog(this.config, 'verbose')) return;\n\n const name = entry.debugName ? `\"${entry.debugName}\"` : entry.fieldKey;\n\n switch (entry.result) {\n case 'applied':\n this.logger.debug(`Derivation - Applied ${name}`, {\n field: entry.fieldKey,\n previousValue: entry.previousValue,\n newValue: entry.newValue,\n ...(entry.durationMs !== undefined && { durationMs: entry.durationMs }),\n });\n break;\n\n case 'skipped':\n this.logger.debug(`Derivation - Skipped ${name} (${this.formatSkipReason(entry.skipReason)})`, {\n field: entry.fieldKey,\n reason: entry.skipReason,\n });\n break;\n\n case 'error':\n this.logger.debug(`Derivation - Error ${name}`, {\n field: entry.fieldKey,\n error: entry.error,\n });\n break;\n }\n }\n\n summary(result: DerivationProcessingResult, trigger: 'onChange' | 'debounced'): void {\n if (!shouldLog(this.config, 'summary')) return;\n\n // Only log if something interesting happened\n if (result.appliedCount === 0 && result.errorCount === 0) return;\n\n this.logger.debug(`Derivation - Cycle complete (${trigger})`, {\n applied: result.appliedCount,\n skipped: result.skippedCount,\n errors: result.errorCount,\n iterations: result.iterations,\n });\n }\n\n maxIterationsReached(result: DerivationProcessingResult, trigger: 'onChange' | 'debounced'): void {\n this.logger.warn(\n `Derivation - Max iterations reached (${trigger}). ` +\n `This may indicate a loop in derivation logic. ` +\n `Applied: ${result.appliedCount}, Skipped: ${result.skippedCount}, Errors: ${result.errorCount}`,\n );\n }\n\n private formatSkipReason(reason?: string): string {\n switch (reason) {\n case 'condition-false':\n return 'condition not met';\n case 'value-unchanged':\n return 'value unchanged';\n case 'already-applied':\n return 'already applied this cycle';\n case 'user-override':\n return 'user override active';\n case 'target-not-found':\n return 'target not found';\n default:\n return 'unknown';\n }\n }\n}\n\n/**\n * No-op implementation of DerivationLogger.\n * All methods are empty - no logging overhead when disabled.\n *\n * @internal\n */\nclass NoopDerivationLogger implements DerivationLogger {\n cycleStart(): void {\n /* no-op */\n }\n iteration(): void {\n /* no-op */\n }\n evaluation(): void {\n /* no-op */\n }\n summary(): void {\n /* no-op */\n }\n maxIterationsReached(): void {\n /* no-op */\n }\n}\n\n/** Singleton no-op instance to avoid creating new objects */\nconst NOOP_INSTANCE = new NoopDerivationLogger();\n\n/**\n * Factory function to create a DerivationLogger.\n *\n * @param config - The derivation log configuration\n * @param logger - The underlying logger instance\n * @returns A DerivationLogger instance\n */\nexport function createDerivationLogger(config: DerivationLogConfig | undefined, logger: Logger): DerivationLogger {\n const effectiveConfig = config ?? createDefaultDerivationLogConfig();\n\n // Return no-op if logging is completely disabled\n if (effectiveConfig.level === 'none') {\n return NOOP_INSTANCE;\n }\n\n // Create active logger with config\n const service = new ActiveDerivationLogger();\n service.logger = logger;\n service.setConfig(effectiveConfig);\n\n return service;\n}\n","/**\n * Utilities for parsing and manipulating field paths in dynamic forms.\n *\n * @module path-utils\n */\n\n/** Result of parsing an array derivation path. */\nexport interface ArrayPathInfo {\n /** The array field path (e.g., \"items\" or \"orders.lineItems\") */\n arrayPath: string;\n\n /** The relative field path within the array item (e.g., \"quantity\" or \"lineTotal\") */\n relativePath: string;\n\n /** Whether this is a valid array path (contains .$. separator) */\n isArrayPath: boolean;\n}\n\n/**\n * Parses an array derivation path into its components.\n *\n * @param path - The path to parse (e.g., \"items.$.quantity\")\n * @returns Parsed path info with arrayPath and relativePath\n */\nexport function parseArrayPath(path: string): ArrayPathInfo {\n const ARRAY_PLACEHOLDER = '.$.';\n const placeholderIndex = path.indexOf(ARRAY_PLACEHOLDER);\n\n if (placeholderIndex === -1) {\n return {\n arrayPath: '',\n relativePath: '',\n isArrayPath: false,\n };\n }\n\n return {\n arrayPath: path.substring(0, placeholderIndex),\n relativePath: path.substring(placeholderIndex + ARRAY_PLACEHOLDER.length),\n isArrayPath: true,\n };\n}\n\n/**\n * Resolves an array placeholder path to a concrete indexed path.\n *\n * @param path - The path with $ placeholder (e.g., \"items.$.quantity\")\n * @param index - The array index to substitute\n * @returns Resolved path with index (e.g., \"items.0.quantity\")\n */\nexport function resolveArrayPath(path: string, index: number): string {\n const info = parseArrayPath(path);\n if (!info.isArrayPath) {\n return path;\n }\n return `${info.arrayPath}.${index}.${info.relativePath}`;\n}\n\n/**\n * Checks if a path contains an array placeholder.\n *\n * @param path - The path to check\n * @returns True if the path contains .$.\n */\nexport function isArrayPlaceholderPath(path: string): boolean {\n return path.includes('.$.');\n}\n\n/**\n * Extracts the array path from a path that may contain an array placeholder.\n *\n * @param path - The path to extract from\n * @returns The array path, or empty string if not an array path\n */\nexport function extractArrayPath(path: string): string {\n return parseArrayPath(path).arrayPath;\n}\n\n/**\n * Splits a path into segments, supporting both dot notation and bracket notation.\n *\n * @param path - The path to split\n * @returns Array of path segments\n */\nexport function splitPath(path: string): string[] {\n if (!path) return [];\n // Convert bracket notation [n] to .n, then split by dots\n // Filter out empty segments from leading/trailing/consecutive dots\n return path\n .replace(/\\[(\\d+)\\]/g, '.$1')\n .split('.')\n .filter(Boolean);\n}\n\n/**\n * Joins path segments into a dot-separated path.\n *\n * @param segments - The path segments to join\n * @returns Joined path string\n */\nexport function joinPath(segments: string[]): string {\n return segments.join('.');\n}\n\n/**\n * Gets the parent path from a nested path.\n *\n * @param path - The path to get parent from\n * @returns The parent path, or empty string if no parent\n */\nexport function getParentPath(path: string): string {\n const segments = splitPath(path);\n if (segments.length <= 1) return '';\n return joinPath(segments.slice(0, -1));\n}\n\n/**\n * Gets the last segment (leaf) from a path.\n *\n * @param path - The path to get leaf from\n * @returns The last segment\n */\nexport function getLeafPath(path: string): string {\n const segments = splitPath(path);\n return segments[segments.length - 1] ?? '';\n}\n\n// ============================================================================\n// Multi-Array Path Utilities\n// ============================================================================\n\n/** Result of parsing a path with multiple array placeholders. */\nexport interface MultiArrayPathInfo {\n /** Whether this path contains array placeholders */\n isArrayPath: boolean;\n\n /** Number of array placeholders in the path */\n placeholderCount: number;\n\n /** Path segments (excluding $ placeholders) */\n segments: string[];\n\n /** The positions of '$' in the split path (by part index) */\n placeholderPositions: number[];\n}\n\n/**\n * Parses a path that may contain multiple array placeholders.\n *\n * @param path - Path with zero or more $ placeholders\n * @returns Parsed path information\n */\nexport function parseMultiArrayPath(path: string): MultiArrayPathInfo {\n const parts = path.split('.');\n const segments: string[] = [];\n const placeholderPositions: number[] = [];\n\n for (let i = 0; i < parts.length; i++) {\n if (parts[i] === '$') {\n placeholderPositions.push(i);\n } else {\n segments.push(parts[i]);\n }\n }\n\n return {\n isArrayPath: placeholderPositions.length > 0,\n placeholderCount: placeholderPositions.length,\n segments,\n placeholderPositions,\n };\n}\n\n/**\n * Resolves a path with multiple array placeholders using provided indices.\n *\n * @param path - Path with $ placeholders (e.g., 'orders.$.items.$.quantity')\n * @param indices - Array of indices to substitute (e.g., [0, 2])\n * @returns Resolved path (e.g., 'orders.0.items.2.quantity')\n * @throws Error if number of indices doesn't match number of placeholders\n */\nexport function resolveMultiArrayPath(path: string, indices: number[]): string {\n const parts = path.split('.');\n let indexPointer = 0;\n\n const resolved = parts.map((part) => {\n if (part === '$') {\n if (indexPointer >= indices.length) {\n throw new Error(\n `Not enough indices provided for path '${path}'. ` +\n `Expected ${parts.filter((p) => p === '$').length} indices, got ${indices.length}.`,\n );\n }\n return String(indices[indexPointer++]);\n }\n return part;\n });\n\n if (indexPointer < indices.length) {\n throw new Error(`Too many indices provided for path '${path}'. ` + `Expected ${indexPointer} indices, got ${indices.length}.`);\n }\n\n return resolved.join('.');\n}\n\n/**\n * Counts the number of array placeholders in a path.\n *\n * @param path - The path to analyze\n * @returns Number of $ placeholders in the path\n */\nexport function countArrayPlaceholders(path: string): number {\n return path.split('.').filter((part) => part === '$').length;\n}\n","import type { FormFieldStateMap } from '@ng-forge/dynamic-forms/internal';\nimport { HttpRequestConfig } from '@ng-forge/dynamic-forms/internal';\nimport type { AsyncDerivationFunction } from '@ng-forge/dynamic-forms/internal';\nimport { BaseDerivationEntry } from './derivation-entry-base';\n\n/** Entry representing a collected derivation from field definitions. */\nexport interface DerivationEntry extends BaseDerivationEntry {\n /**\n * Target property name when this entry feeds the property-override store\n * (one of `'hidden'`, `'disabled'`, `'readonly'`, ...). When absent, the\n * entry is a value derivation and writes into the FieldTree.\n */\n targetProperty?: string;\n\n /** HTTP request configuration for server-driven derivations. */\n http?: HttpRequestConfig;\n\n /** Name of a registered async derivation function. */\n asyncFunctionName?: string;\n\n /**\n * Inline async derivation function (code-only authoring).\n * Mutually exclusive with `asyncFunctionName`. NOT JSON-serializable.\n * Processed asynchronously in a dedicated RxJS stream, not in the sync loop.\n */\n asyncFn?: AsyncDerivationFunction;\n\n /** Expression to extract the derived value from an HTTP response. */\n responseExpression?: string;\n\n /**\n * Whether this derivation was created from the shorthand `derivation` property.\n * Value-pipeline-only — property derivations are always created from full\n * logic configs and leave this unset.\n */\n isShorthand?: boolean;\n\n /**\n * When true, the derivation stops running after the user manually\n * edits the target field. Value-pipeline-only — property derivations\n * always recompute on dependency change.\n */\n stopOnUserOverride?: boolean;\n\n /**\n * When true (and `stopOnUserOverride` is also true), clears the\n * user-override flag when any dependency of this derivation changes,\n * allowing the derivation to run again. Value-pipeline-only.\n */\n reEngageOnDependencyChange?: boolean;\n}\n\n/** Collection of all derivation entries from a form's field definitions. */\nexport interface DerivationCollection {\n /**\n * All derivation entries collected from field definitions,\n * sorted in topological order for efficient processing.\n */\n entries: DerivationEntry[];\n}\n\n/** Result of cycle detection in the derivation graph. */\nexport interface CycleDetectionResult {\n /** Whether a cycle was detected. */\n hasCycle: boolean;\n\n /** Field paths involved in the cycle, if one was detected. */\n cyclePath?: string[];\n\n /** Bidirectional derivation pairs detected (A↔B patterns). */\n bidirectionalPairs?: string[];\n\n /** Human-readable error message describing the cycle. */\n errorMessage?: string;\n}\n\n/**\n * Context for tracking derivation chain during runtime.\n *\n * @internal\n */\nexport interface DerivationChainContext {\n /** Set of derivation keys that have already been applied. */\n appliedDerivations: Set<string>;\n\n /** Current iteration count in the derivation processing loop. */\n iteration: number;\n\n /** Set of field keys that changed in this cycle. */\n changedFields?: Set<string>;\n\n /** Cached FormFieldStateMap for this cycle. */\n formFieldState?: FormFieldStateMap;\n}\n\n/**\n * Creates an empty derivation collection.\n *\n * @returns Empty collection ready for population\n * @internal\n */\nexport function createEmptyDerivationCollection(): DerivationCollection {\n return { entries: [] };\n}\n\n/**\n * Creates a new derivation chain context for tracking applied derivations.\n *\n * @returns Fresh context for a new derivation cycle\n * @internal\n */\nexport function createDerivationChainContext(): DerivationChainContext {\n return {\n appliedDerivations: new Set(),\n iteration: 0,\n };\n}\n\n/**\n * Creates a unique key for a derivation entry.\n *\n * @param fieldKey - The field key (with array index already resolved)\n * @returns Unique key for the derivation\n * @internal\n */\nexport function createDerivationKey(fieldKey: string): string {\n return fieldKey;\n}\n\n/**\n * Parses a derivation key back into field key.\n *\n * @param key - The derivation key to parse\n * @returns Object containing the field key\n * @internal\n */\nexport function parseDerivationKey(key: string): { fieldKey: string } {\n return { fieldKey: key };\n}\n\n/** Result of processing all pending derivations. */\nexport interface DerivationProcessingResult {\n /** Number of derivations successfully applied */\n appliedCount: number;\n\n /** Number of derivations skipped (condition not met or value unchanged) */\n skippedCount: number;\n\n /** Number of derivations that failed with errors */\n errorCount: number;\n\n /** Number of derivations that encountered warnings (e.g., missing target field). */\n warnCount: number;\n\n /** Total iterations performed */\n iterations: number;\n\n /** Whether max iterations was reached (potential loop) */\n maxIterationsReached: boolean;\n}\n","import { DynamicFormError } from '@ng-forge/dynamic-forms/internal';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport { ExpressionParser } from '@ng-forge/dynamic-forms/internal';\nimport { BaseDerivationEntry } from './derivation-entry-base';\n\n/**\n * Inputs accepted by {@link computeValueFromEntry}.\n *\n * @internal\n */\nexport interface ComputeValueOptions {\n /** Registered derivation functions, used when the entry has `functionName`. */\n derivationFunctions?: Record<string, CustomFunction>;\n\n /**\n * Subject label used in the \"no value source\" error message — typically the\n * resolved field key (e.g. `'items.0.lineTotal'`) or `${fieldKey}.${targetProperty}`\n * for property derivations.\n */\n subject: string;\n\n /**\n * Optional descriptor (e.g., `'Property derivation function'`) used in the\n * \"function not found\" error. Defaults to `'Derivation function'`.\n */\n functionKind?: string;\n}\n\n/** True for a Promise/Observable wrongly returned into a sync derivation slot. */\nfunction isThenableOrObservable(value: unknown): boolean {\n if (value === null || typeof value !== 'object') return false;\n const candidate = value as { then?: unknown; subscribe?: unknown };\n return typeof candidate.then === 'function' || typeof candidate.subscribe === 'function';\n}\n\n/**\n * Single dispatch over `value` / `expression` / `functionName` shared by both\n * the value-derivation and property-derivation applicators.\n *\n * @internal\n */\nexport function computeValueFromEntry(\n entry: Pick<BaseDerivationEntry, 'value' | 'expression' | 'functionName' | 'fn'>,\n evalContext: EvaluationContext,\n options: ComputeValueOptions,\n): unknown {\n if (entry.value !== undefined) {\n return entry.value;\n }\n\n if (entry.expression) {\n return ExpressionParser.evaluate(entry.expression, evalContext);\n }\n\n if (entry.fn || entry.functionName) {\n if (entry.fn && entry.functionName) {\n evalContext.logger.warn(\n `Both \"fn\" and \"functionName\" are set on derivation for '${options.subject}'. Inline \"fn\" takes precedence; \"functionName\" is ignored.`,\n );\n }\n const fn = entry.fn ?? (entry.functionName ? options.derivationFunctions?.[entry.functionName] : undefined);\n if (!fn) {\n const kind = options.functionKind ?? 'Derivation function';\n throw new DynamicFormError(`${kind} '${entry.functionName}' not found in customFnConfig.derivations`);\n }\n const result = fn(evalContext);\n if (isThenableOrObservable(result)) {\n evalContext.logger.warn(\n `Derivation for '${options.subject}' returned a Promise or Observable. ` +\n `Synchronous derivations must return a value; use 'asyncDerivations' or an HTTP derivation for asynchronous logic. Skipping.`,\n );\n return undefined;\n }\n return result;\n }\n\n throw new DynamicFormError(\n `Derivation for ${options.subject} has no value source. Specify 'value', 'expression', 'functionName', or 'fn'.`,\n );\n}\n","import { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { ArrayPathInfo } from '../../utils/path-utils/path-utils';\n\n/**\n * Returns the path to the parent of the given field path. Drops the last\n * `.`-delimited segment. Returns `undefined` when the field has no parent\n * in scope (single-segment root key).\n *\n * @internal\n */\nexport function getParentPathInScope(fieldKey: string): string | undefined {\n const lastDot = fieldKey.lastIndexOf('.');\n if (lastDot <= 0) return undefined;\n return fieldKey.slice(0, lastDot);\n}\n\n/**\n * Per-array-item scope values resolved from a parsed array path.\n *\n * @internal\n */\nexport interface ArrayItemScope {\n /** Path within the array item (everything after `'.$.'`); `''` for non-array entries. */\n readonly relativePath: string;\n readonly groupValue: unknown;\n readonly fieldValue: unknown;\n}\n\n/**\n * Resolves the per-array-item scope shared by both derivation applicators.\n *\n * @internal\n */\nexport function resolveArrayItemScope(pathInfo: ArrayPathInfo, arrayItem: Record<string, unknown>): ArrayItemScope {\n const relativePath = pathInfo.isArrayPath ? pathInfo.relativePath : '';\n const innerParentPath = relativePath.includes('.') ? relativePath.slice(0, relativePath.lastIndexOf('.')) : '';\n const groupValue = innerParentPath ? getNestedValue(arrayItem, innerParentPath) : arrayItem;\n const fieldValue = relativePath ? getNestedValue(arrayItem, relativePath) : arrayItem;\n return { relativePath, groupValue, fieldValue };\n}\n","/**\n * Centralized constants for the derivation system.\n *\n * @module\n */\n\n/** Maximum number of derivation iterations before stopping to prevent infinite loops. */\nexport const MAX_DERIVATION_ITERATIONS = 10;\n\n/** Placeholder string for array index in derivation paths. */\nexport const ARRAY_PLACEHOLDER = '.$.';\n\n/** Maximum size of the expression AST cache. */\nexport const MAX_AST_CACHE_SIZE = 1000;\n\n/** Delimiter used to create unique derivation keys from source and target field keys. */\nexport const DERIVATION_KEY_DELIMITER = '\\x00';\n","import { isWritableSignal, untracked } from '@angular/core';\nimport type { FieldState, FieldTree } from '@angular/forms/signals';\nimport type { FieldTreeRecord } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport type { WarningTracker } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Navigates the form tree to resolve a field instance at the given path.\n *\n * @returns The field instance object, or `undefined` if the path is invalid\n * @internal\n */\nexport function resolveFieldInstance(rootForm: FieldTree<unknown>, fieldPath: string): FieldState<unknown> | undefined {\n const parts = fieldPath.split('.');\n let current = rootForm as FieldTreeRecord;\n\n for (let i = 0; i < parts.length - 1; i++) {\n const next = current[parts[i]];\n if (!next) return undefined;\n current = next as FieldTreeRecord;\n }\n\n const fieldAccessor = current[parts[parts.length - 1]];\n if (!fieldAccessor) return undefined;\n\n return untracked(fieldAccessor);\n}\n\n/**\n * Reads the dirty() signal from a field at the given path.\n *\n * @internal\n */\nexport function readFieldDirty(rootForm: FieldTree<unknown>, fieldPath: string): boolean | undefined {\n const fieldInstance = resolveFieldInstance(rootForm, fieldPath);\n if (!fieldInstance) return undefined;\n return untracked(fieldInstance.dirty);\n}\n\n/**\n * Resets a field's dirty/touched state at the given path.\n *\n * @internal\n */\nexport function resetFieldState(rootForm: FieldTree<unknown>, fieldPath: string): void {\n const fieldInstance = resolveFieldInstance(rootForm, fieldPath);\n if (!fieldInstance) return;\n fieldInstance.reset();\n}\n\n/**\n * Applies a value to the form at the specified path.\n *\n * @internal\n */\nexport function applyValueToForm(\n targetPath: string,\n value: unknown,\n rootForm: FieldTree<unknown>,\n logger?: Logger,\n warningTracker?: WarningTracker,\n): boolean {\n // Handle simple top-level fields\n if (!targetPath.includes('.')) {\n return setFieldValue(rootForm as FieldTreeRecord, targetPath, value, logger, warningTracker);\n }\n\n // Handle nested paths (e.g., 'address.city' or 'items.$.quantity')\n const parts = targetPath.split('.');\n let current = rootForm as FieldTreeRecord;\n\n for (let i = 0; i < parts.length - 1; i++) {\n const part = parts[i];\n\n // Handle array index placeholder '$'\n if (part === '$') {\n // This case should be resolved at collection time\n // If we still have '$', we need to skip for now\n return false;\n }\n\n // Navigate to the next level using bracket notation\n const next = current[part];\n if (!next) {\n warnMissingField(targetPath, logger, warningTracker);\n return false; // Path doesn't exist\n }\n current = next as FieldTreeRecord;\n }\n\n // Set the final value\n const finalPart = parts[parts.length - 1];\n return setFieldValue(current, finalPart, value, logger, warningTracker);\n}\n\n/**\n * Sets a field value using the Angular Signal Forms pattern.\n *\n * @returns true if the value was successfully set, false if field not found\n * @internal\n */\nfunction setFieldValue(\n parent: FieldTreeRecord,\n fieldKey: string,\n value: unknown,\n logger?: Logger,\n warningTracker?: WarningTracker,\n): boolean {\n const fieldAccessor = parent[fieldKey];\n\n if (!fieldAccessor) {\n warnMissingField(fieldKey, logger, warningTracker);\n return false;\n }\n\n // Call the FieldTree to get the FieldState\n const fieldInstance = untracked(fieldAccessor);\n\n if (!fieldInstance || typeof fieldInstance !== 'object') {\n warnMissingField(fieldKey, logger, warningTracker);\n return false;\n }\n\n // Get the value signal and verify it's a WritableSignal\n const valueSignal = fieldInstance.value;\n\n if (isWritableSignal(valueSignal)) {\n // Writing directly to value.set() does NOT trigger markAsDirty() —\n // only user interaction through setControlValue() does. So derivation-applied\n // values leave the field pristine, and dirty() reliably indicates user edits.\n valueSignal.set(value);\n return true;\n } else {\n warnMissingField(fieldKey, logger, warningTracker);\n return false;\n }\n}\n\n/**\n * Logs a warning for a missing target field (once per field per form instance).\n *\n * @internal\n */\nexport function warnMissingField(fieldKey: string, logger?: Logger, warningTracker?: WarningTracker): void {\n // If tracker is provided, check if we've already warned about this field\n if (warningTracker) {\n if (warningTracker.warnedKeys.has(fieldKey)) {\n return;\n }\n warningTracker.warnedKeys.add(fieldKey);\n }\n\n logger?.warn(\n `Derivation: target field '${fieldKey}' not found in form. ` +\n `Ensure the field is defined in your form configuration. ` +\n `This warning is shown once per field.`,\n );\n}\n","import { Signal, untracked } from '@angular/core';\nimport type { FieldTree } from '@angular/forms/signals';\nimport { ConditionalExpression } from '@ng-forge/dynamic-forms/internal';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport type { FieldStateInfo } from '@ng-forge/dynamic-forms/internal';\nimport type { FieldTreeRecord } from '@ng-forge/dynamic-forms/internal';\nimport { isEqual } from '@ng-forge/dynamic-forms/internal';\nimport { parseArrayPath, resolveArrayPath, isArrayPlaceholderPath } from '../../utils/path-utils/path-utils';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport { evaluateCondition } from '@ng-forge/dynamic-forms/internal';\nimport { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport {\n createDerivationChainContext,\n createDerivationKey,\n DerivationChainContext,\n DerivationCollection,\n DerivationEntry,\n DerivationProcessingResult,\n} from './derivation-types';\nimport type { WarningTracker } from '@ng-forge/dynamic-forms/internal';\nimport { computeValueFromEntry } from './compute-derived-value';\nimport { getParentPathInScope, resolveArrayItemScope } from './evaluation-scope';\nimport { MAX_DERIVATION_ITERATIONS } from './derivation-constants';\nimport { DerivationLogger } from './derivation-logger.service';\nimport { readFieldStateInfo, createFormFieldStateMap } from '@ng-forge/dynamic-forms/internal';\nimport { applyValueToForm, readFieldDirty, resetFieldState } from './field-value-utils';\n\n// Re-export for backwards compatibility\nexport type { DerivationProcessingResult } from './derivation-types';\n\n/** Context required for applying derivations. */\nexport interface DerivationApplicatorContext {\n /** The current form value (signal) */\n formValue: Signal<Record<string, unknown>>;\n\n /** Root form field tree for value updates */\n rootForm: FieldTree<unknown>;\n\n /** Registered custom derivation functions */\n derivationFunctions?: Record<string, CustomFunction>;\n\n /** Custom functions for expression evaluation */\n customFunctions?: Record<string, CustomFunction>;\n\n /**\n * External data resolved from form config signals.\n * Made available in evaluation context as `externalData`.\n */\n externalData?: Record<string, unknown>;\n\n /** Logger for diagnostic output */\n logger: Logger;\n\n /**\n * Instance-scoped warning tracker to prevent log spam.\n * If not provided, warnings will be logged every time.\n */\n warningTracker?: WarningTracker;\n\n /**\n * Derivation logger for debug output.\n * Created via `createDerivationLogger()` factory.\n */\n derivationLogger: DerivationLogger;\n\n /**\n * Maximum number of iterations for derivation chain processing.\n * Falls back to MAX_DERIVATION_ITERATIONS constant if not provided.\n */\n maxIterations?: number;\n}\n\n/**\n * Result of a single derivation application.\n *\n * @internal\n */\ninterface DerivationResult {\n /** Whether the derivation was applied */\n applied: boolean;\n\n /** The field key */\n fieldKey: string;\n\n /** The computed value */\n newValue?: unknown;\n\n /** Error message if application failed */\n error?: string;\n\n /** Warning message (e.g., missing target field) - not a failure, but noteworthy */\n warning?: string;\n}\n\n/**\n * Applies all pending derivations from a collection.\n *\n * @param collection - The collected derivation entries\n * @param context - Context for applying derivations\n * @param changedFields - Set of field keys that changed (for filtering)\n * @returns Result of the derivation processing\n */\nexport function applyDerivations(\n collection: DerivationCollection,\n context: DerivationApplicatorContext,\n changedFields?: Set<string>,\n): DerivationProcessingResult {\n const chainContext = createDerivationChainContext();\n chainContext.changedFields = changedFields;\n // Cache formFieldState map per cycle to avoid allocating a new Proxy + Map per entry\n chainContext.formFieldState = createFormFieldStateMap(context.rootForm, false);\n const { derivationLogger } = context;\n const maxIterations = context.maxIterations ?? MAX_DERIVATION_ITERATIONS;\n let appliedCount = 0;\n let skippedCount = 0;\n let errorCount = 0;\n let warnCount = 0;\n\n // Filter entries based on changed fields\n const entriesToProcess = changedFields ? getEntriesForChangedFields(collection.entries, changedFields) : collection.entries;\n\n // Log cycle start in verbose mode\n derivationLogger.cycleStart('onChange', entriesToProcess.length);\n\n // Process derivations iteratively until no more changes\n let hasChanges = true;\n\n while (hasChanges && chainContext.iteration < maxIterations) {\n chainContext.iteration++;\n hasChanges = false;\n\n // Log iteration in verbose mode\n derivationLogger.iteration(chainContext.iteration);\n\n for (const entry of entriesToProcess) {\n const result = tryApplyDerivation(entry, context, chainContext);\n\n if (result.applied) {\n appliedCount++;\n hasChanges = true;\n } else if (result.error) {\n errorCount++;\n } else if (result.warning) {\n warnCount++;\n skippedCount++;\n } else {\n skippedCount++;\n }\n }\n }\n\n // Build result\n const processingResult: DerivationProcessingResult = {\n appliedCount,\n skippedCount,\n errorCount,\n warnCount,\n iterations: chainContext.iteration,\n maxIterationsReached: chainContext.iteration >= maxIterations,\n };\n\n if (processingResult.maxIterationsReached) {\n derivationLogger.maxIterationsReached(processingResult, 'onChange');\n }\n\n // Log summary\n derivationLogger.summary(processingResult, 'onChange');\n\n return processingResult;\n}\n\n/**\n * Gets entries that should be processed based on changed fields.\n *\n * @internal\n */\nfunction getEntriesForChangedFields(entries: DerivationEntry[], changedFields: Set<string>): DerivationEntry[] {\n return entries.filter((entry) => {\n // Entries with no dependencies or wildcard dependencies always run\n if (entry.dependsOn.length === 0 || entry.dependsOn.includes('*')) {\n return true;\n }\n\n // Check if any dependency matches a changed field directly\n if (entry.dependsOn.some((dep) => changedFields.has(dep))) {\n return true;\n }\n\n // For array entries (fieldKey contains '$'), check if the entry's parent\n // array key changed. Array derivations have relative dependencies\n // (e.g., 'quantity') but changedFields contains root keys (e.g., 'lineItems').\n for (const changed of changedFields) {\n // Check if this entry lives under a changed parent (array or nested)\n if (entry.fieldKey.startsWith(changed + '.')) return true;\n // Check if a dependency is nested under a changed parent\n if (entry.dependsOn.some((dep) => dep.startsWith(changed + '.'))) return true;\n }\n\n return false;\n });\n}\n\n/**\n * Attempts to apply a single derivation.\n *\n * @internal\n */\nfunction tryApplyDerivation(\n entry: DerivationEntry,\n context: DerivationApplicatorContext,\n chainContext: DerivationChainContext,\n): DerivationResult {\n const { derivationLogger } = context;\n\n // HTTP and async entries are processed asynchronously in their own streams — skip in sync loop\n if (entry.http || entry.asyncFunctionName || entry.asyncFn) {\n return { applied: false, fieldKey: entry.fieldKey };\n }\n\n // Check if this is an array field derivation (has '$' placeholder)\n if (isArrayPlaceholderPath(entry.fieldKey)) {\n return tryApplyArrayDerivation(entry, context, chainContext);\n }\n\n const derivationKey = createDerivationKey(entry.fieldKey);\n\n // Check if already applied in this cycle\n if (chainContext.appliedDerivations.has(derivationKey)) {\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'already-applied',\n });\n return { applied: false, fieldKey: entry.fieldKey };\n }\n\n // Check stopOnUserOverride — skip if the user has manually edited the target field\n if (shouldSkipForUserOverride(entry, entry.fieldKey, context, chainContext)) {\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'user-override',\n });\n return { applied: false, fieldKey: entry.fieldKey };\n }\n\n // Create evaluation context\n const formValue = untracked(() => context.formValue());\n const evalContext = createEvaluationContext(entry, formValue, context, chainContext);\n\n // Evaluate condition\n if (!evaluateDerivationCondition(entry.condition, evalContext)) {\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'condition-false',\n });\n return { applied: false, fieldKey: entry.fieldKey };\n }\n\n // Compute derived value\n let newValue: unknown;\n try {\n newValue = computeDerivedValue(entry, evalContext, context);\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n context.logger.error(formatDerivationError(entry, 'compute', errorMessage));\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'error',\n error: errorMessage,\n });\n return {\n applied: false,\n fieldKey: entry.fieldKey,\n error: errorMessage,\n };\n }\n\n // Check if value actually changed using exact equality.\n // Note: This uses isEqual which performs deep comparison with exact IEEE 754\n // equality for numbers. Bidirectional derivations with floating-point math\n // may oscillate due to rounding errors. Use explicit rounding in expressions\n // or integer arithmetic to avoid this issue.\n const currentValue = getNestedValue(formValue, entry.fieldKey);\n if (isEqual(currentValue, newValue)) {\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'value-unchanged',\n });\n return { applied: false, fieldKey: entry.fieldKey };\n }\n\n // Apply the value\n try {\n const wasApplied = applyValueToForm(entry.fieldKey, newValue, context.rootForm, context.logger, context.warningTracker);\n\n if (wasApplied) {\n chainContext.appliedDerivations.add(derivationKey);\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'applied',\n previousValue: currentValue,\n newValue,\n });\n return { applied: true, fieldKey: entry.fieldKey, newValue };\n } else {\n // Field not found - this is a warning, not an error\n // The warning was already logged by warnMissingField\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'target-not-found',\n });\n return {\n applied: false,\n fieldKey: entry.fieldKey,\n warning: `Field '${entry.fieldKey}' not found`,\n };\n }\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n context.logger.error(formatDerivationError(entry, 'apply', errorMessage));\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'error',\n error: errorMessage,\n });\n return {\n applied: false,\n fieldKey: entry.fieldKey,\n error: errorMessage,\n };\n }\n}\n\n/**\n * Handles array field derivations with '$' placeholder.\n *\n * @internal\n */\nfunction tryApplyArrayDerivation(\n entry: DerivationEntry,\n context: DerivationApplicatorContext,\n chainContext: DerivationChainContext,\n): DerivationResult {\n const formValue = untracked(() => context.formValue());\n\n // Parse the field path using path utilities\n const pathInfo = parseArrayPath(entry.fieldKey);\n if (!pathInfo.isArrayPath) {\n return { applied: false, fieldKey: entry.fieldKey, error: 'Invalid array derivation path' };\n }\n\n const { arrayPath } = pathInfo;\n\n // Get the array from form values\n const arrayValue = getNestedValue(formValue, arrayPath);\n if (!Array.isArray(arrayValue)) {\n return { applied: false, fieldKey: entry.fieldKey };\n }\n\n let appliedAny = false;\n\n // Process each array item\n for (let i = 0; i < arrayValue.length; i++) {\n const resolvedPath = resolveArrayPath(entry.fieldKey, i);\n const derivationKey = createDerivationKey(resolvedPath);\n\n // Skip if already applied in this cycle\n if (chainContext.appliedDerivations.has(derivationKey)) {\n continue;\n }\n\n // Check stopOnUserOverride for array items using dirty() signal\n if (shouldSkipForUserOverride(entry, resolvedPath, context, chainContext)) {\n context.derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: resolvedPath,\n result: 'skipped',\n skipReason: 'user-override',\n });\n continue;\n }\n\n // Create evaluation context scoped to this array item\n const arrayItem = arrayValue[i] as Record<string, unknown>;\n const evalContext = createArrayItemEvaluationContext(entry, arrayItem, formValue, i, arrayPath, context, chainContext);\n\n // Evaluate condition\n if (!evaluateDerivationCondition(entry.condition, evalContext)) {\n continue;\n }\n\n // Compute derived value\n let newValue: unknown;\n try {\n newValue = computeDerivedValue(entry, evalContext, context);\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n context.logger.error(formatDerivationError(entry, 'compute', `[index ${i}] ${errorMessage}`));\n continue;\n }\n\n // Check if value actually changed\n const currentValue = getNestedValue(formValue, resolvedPath);\n if (isEqual(currentValue, newValue)) {\n continue;\n }\n\n // Apply the value\n try {\n applyValueToForm(resolvedPath, newValue, context.rootForm, context.logger, context.warningTracker);\n chainContext.appliedDerivations.add(derivationKey);\n appliedAny = true;\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n context.logger.error(formatDerivationError(entry, 'apply', `[index ${i}] ${errorMessage}`));\n }\n }\n\n return { applied: appliedAny, fieldKey: entry.fieldKey };\n}\n\n/**\n * Creates an evaluation context for derivation processing.\n *\n * @internal\n */\nfunction createEvaluationContext(\n entry: DerivationEntry,\n formValue: Record<string, unknown>,\n context: DerivationApplicatorContext,\n chainContext: DerivationChainContext,\n): EvaluationContext {\n const fieldValue = getNestedValue(formValue, entry.fieldKey);\n\n // Create field state snapshot for this specific field (non-reactive)\n const fieldAccessor = (context.rootForm as FieldTreeRecord)[entry.fieldKey];\n const fieldState = fieldAccessor ? readFieldStateInfo(fieldAccessor, false) : undefined;\n\n // Parent group value: the absolute path with the trailing field segment\n // dropped. `undefined` when the field is at form root.\n const parentPath = getParentPathInScope(entry.fieldKey);\n const groupValue = parentPath ? getNestedValue(formValue, parentPath) : undefined;\n\n return {\n fieldValue,\n formValue,\n fieldPath: entry.fieldKey,\n groupValue,\n customFunctions: context.customFunctions,\n externalData: context.externalData,\n logger: context.logger,\n fieldState,\n formFieldState: chainContext.formFieldState,\n };\n}\n\n/**\n * Creates an evaluation context scoped to a specific array item.\n *\n * @internal\n */\nfunction createArrayItemEvaluationContext(\n entry: DerivationEntry,\n arrayItem: Record<string, unknown>,\n rootFormValue: Record<string, unknown>,\n itemIndex: number,\n arrayPath: string,\n context: DerivationApplicatorContext,\n chainContext: DerivationChainContext,\n): EvaluationContext {\n // For array item expressions, formValue should be the array item\n // This allows expressions like 'formValue.quantity * formValue.unitPrice'\n // to work within the context of each array item\n\n // Create field state snapshot for the specific array item field\n // Navigate: rootForm[arrayPath][index][fieldKey]\n const pathInfo = parseArrayPath(entry.fieldKey);\n let fieldState: FieldStateInfo | undefined;\n\n if (pathInfo.isArrayPath && pathInfo.relativePath) {\n const rootFormRecord = context.rootForm as FieldTreeRecord;\n const arrayAccessor = rootFormRecord[arrayPath];\n if (arrayAccessor) {\n const arrayItems = arrayAccessor as FieldTreeRecord;\n const itemAccessor = arrayItems[String(itemIndex)];\n if (itemAccessor) {\n const itemFields = itemAccessor as FieldTreeRecord;\n const fieldAccessor = itemFields[pathInfo.relativePath];\n if (fieldAccessor) {\n fieldState = readFieldStateInfo(fieldAccessor, false);\n }\n }\n }\n }\n\n const { relativePath, groupValue, fieldValue } = resolveArrayItemScope(pathInfo, arrayItem);\n\n return {\n fieldValue,\n formValue: arrayItem,\n fieldPath: relativePath ? `${arrayPath}.${itemIndex}.${relativePath}` : `${arrayPath}.${itemIndex}`,\n groupValue,\n customFunctions: context.customFunctions,\n externalData: context.externalData,\n logger: context.logger,\n // Provide access to root form value for cross-scope references\n rootFormValue,\n arrayIndex: itemIndex,\n arrayPath,\n fieldState,\n formFieldState: chainContext.formFieldState,\n };\n}\n\n/**\n * Evaluates the derivation condition.\n *\n * @internal\n */\nfunction evaluateDerivationCondition(condition: ConditionalExpression | boolean, context: EvaluationContext): boolean {\n if (typeof condition === 'boolean') {\n return condition;\n }\n\n return evaluateCondition(condition, context);\n}\n\n/**\n * Computes the derived value based on the entry configuration.\n *\n * @internal\n */\nfunction computeDerivedValue(\n entry: DerivationEntry,\n evalContext: EvaluationContext,\n applicatorContext: DerivationApplicatorContext,\n): unknown {\n return computeValueFromEntry(entry, evalContext, {\n derivationFunctions: applicatorContext.derivationFunctions,\n subject: entry.fieldKey,\n });\n}\n\n/**\n * Error message prefix for derivation-related errors.\n *\n * @internal\n */\nconst ERROR_PREFIX = '[Derivation]';\n\n/**\n * Creates a standardized error message for derivation errors.\n *\n * @internal\n */\nfunction formatDerivationError(entry: DerivationEntry, phase: 'compute' | 'apply' | 'condition', message: string): string {\n const source = entry.expression\n ? `expression: \"${entry.expression.substring(0, 50)}${entry.expression.length > 50 ? '...' : ''}\"`\n : entry.functionName\n ? `function: \"${entry.functionName}\"`\n : entry.fn\n ? 'inline fn'\n : entry.value !== undefined\n ? 'static value'\n : 'unknown source';\n\n return `${ERROR_PREFIX} Failed to ${phase} derivation (field: ${entry.fieldKey}, ${source}): ${message}`;\n}\n\n/**\n * Checks whether any of the entry's dependencies appear in the changed fields set.\n *\n * @internal\n */\nfunction hasDependencyChanged(entry: DerivationEntry, changedFields: Set<string>): boolean {\n return entry.dependsOn.some((dep) => dep === '*' || changedFields.has(dep));\n}\n\n/**\n * Checks whether a derivation should be skipped due to the user having manually\n * edited the target field. Handles re-engagement when `reEngageOnDependencyChange`\n * is set and a dependency has changed.\n *\n * @returns `true` if the derivation should be skipped, `false` otherwise\n * @internal\n */\nfunction shouldSkipForUserOverride(\n entry: DerivationEntry,\n resolvedFieldKey: string,\n context: DerivationApplicatorContext,\n chainContext: DerivationChainContext,\n): boolean {\n if (!entry.stopOnUserOverride) return false;\n\n const isDirty = readFieldDirty(context.rootForm, resolvedFieldKey) ?? false;\n\n // If the field isn't dirty, no override to skip — and no re-engagement needed.\n // This also avoids wasteful resetFieldState calls on initial form render where\n // changedFields contains all keys (from startWith(null) + pairwise()) but fields\n // are all pristine.\n if (!isDirty) return false;\n\n // Re-engagement: reset dirty state if a dependency changed, allowing re-derivation\n if (entry.reEngageOnDependencyChange && chainContext.changedFields) {\n if (hasDependencyChanged(entry, chainContext.changedFields)) {\n resetFieldState(context.rootForm, resolvedFieldKey);\n // Re-read after reset — field is now pristine, so derivation proceeds\n return false;\n }\n }\n\n // Field is dirty and no re-engagement triggered — skip derivation\n return true;\n}\n\n/**\n * Processes derivations for a specific trigger type.\n *\n * @param collection - The collected derivation entries\n * @param trigger - The trigger type to filter by\n * @param context - Context for applying derivations\n * @param changedFields - Set of field keys that changed (for filtering)\n * @returns Result of the derivation processing\n */\nexport function applyDerivationsForTrigger(\n collection: DerivationCollection,\n trigger: 'onChange' | 'debounced',\n context: DerivationApplicatorContext,\n changedFields?: Set<string>,\n): DerivationProcessingResult {\n // Filter entries by trigger type, excluding HTTP and async entries (processed in async streams)\n const filteredEntries = collection.entries.filter((entry) => {\n if (entry.http || entry.asyncFunctionName || entry.asyncFn) return false;\n if (trigger === 'onChange') {\n return !entry.trigger || entry.trigger === 'onChange';\n }\n return entry.trigger === trigger;\n });\n\n // Create a minimal collection with filtered entries\n const filteredCollection: DerivationCollection = {\n entries: filteredEntries,\n };\n\n return applyDerivations(filteredCollection, context, changedFields);\n}\n\n/**\n * Gets all debounced derivation entries from a collection.\n *\n * @param collection - The collected derivation entries\n * @returns Array of debounced derivation entries\n */\nexport function getDebouncedDerivationEntries(collection: DerivationCollection): DerivationEntry[] {\n return collection.entries.filter((entry) => entry.trigger === 'debounced');\n}\n","import { DerivationEntry } from './derivation-types';\n\n/**\n * Sorts derivation entries in topological order based on their dependencies.\n *\n * @param entries - The derivation entries to sort\n * @returns A new array of entries in topological order\n */\nexport function topologicalSort(entries: DerivationEntry[]): DerivationEntry[] {\n if (entries.length <= 1) {\n return [...entries];\n }\n\n // Build adjacency list and in-degree count\n // An edge A -> B exists if B depends on the target of A\n const graph = new Map<DerivationEntry, Set<DerivationEntry>>();\n const inDegree = new Map<DerivationEntry, number>();\n\n // Initialize\n for (const entry of entries) {\n graph.set(entry, new Set());\n inDegree.set(entry, 0);\n }\n\n // Pre-build index: field key -> entries that produce/target it\n // This enables O(1) lookup instead of O(n) scanning\n const derivationsByField = new Map<string, DerivationEntry[]>();\n for (const entry of entries) {\n // Index by exact field key\n const fieldEntries = derivationsByField.get(entry.fieldKey) ?? [];\n fieldEntries.push(entry);\n derivationsByField.set(entry.fieldKey, fieldEntries);\n\n // Also index array field relative paths for array derivations\n // If entry targets 'items.$.lineTotal', index under 'lineTotal' too\n if (entry.fieldKey.includes('.$.')) {\n const relativePath = entry.fieldKey.split('.$.')[1];\n if (relativePath) {\n const relativeEntries = derivationsByField.get(relativePath) ?? [];\n relativeEntries.push(entry);\n derivationsByField.set(relativePath, relativeEntries);\n }\n }\n }\n\n // Collect entries with wildcard dependencies (need special handling)\n const wildcardDependents: DerivationEntry[] = [];\n for (const entry of entries) {\n if (entry.dependsOn.includes('*')) {\n wildcardDependents.push(entry);\n }\n }\n\n // Build edges using pre-computed index\n // For each entry B, find all entries A whose field is in B's dependencies\n // If B depends on field X, and A produces X, then A must run before B\n for (const entryB of entries) {\n for (const dep of entryB.dependsOn) {\n // Skip wildcards here - handled separately below\n if (dep === '*') continue;\n\n // O(1) lookup: find all derivations that produce this dependency\n const producers = derivationsByField.get(dep);\n if (producers) {\n for (const entryA of producers) {\n const entryAEdges = graph.get(entryA);\n if (entryA !== entryB && entryAEdges && !entryAEdges.has(entryB)) {\n entryAEdges.add(entryB);\n inDegree.set(entryB, (inDegree.get(entryB) ?? 0) + 1);\n }\n }\n }\n }\n }\n\n // Handle wildcard dependencies: they depend on ALL producers\n for (const entryB of wildcardDependents) {\n for (const entryA of entries) {\n const entryAEdges = graph.get(entryA);\n if (entryA !== entryB && entryAEdges && !entryAEdges.has(entryB)) {\n entryAEdges.add(entryB);\n inDegree.set(entryB, (inDegree.get(entryB) ?? 0) + 1);\n }\n }\n }\n\n // Kahn's algorithm: process nodes with in-degree 0\n const queue: DerivationEntry[] = [];\n const sorted: DerivationEntry[] = [];\n\n // Start with entries that have no dependencies on other derivation outputs\n for (const entry of entries) {\n if (inDegree.get(entry) === 0) {\n queue.push(entry);\n }\n }\n\n while (queue.length > 0) {\n const current = queue.shift();\n if (!current) break; // TypeScript guard (shouldn't happen due to while condition)\n sorted.push(current);\n\n // Reduce in-degree for all neighbors\n const neighbors = graph.get(current) ?? new Set<DerivationEntry>();\n for (const neighbor of neighbors) {\n const newDegree = (inDegree.get(neighbor) ?? 0) - 1;\n inDegree.set(neighbor, newDegree);\n\n if (newDegree === 0) {\n queue.push(neighbor);\n }\n }\n }\n\n // If we couldn't sort all entries, there's a cycle\n // (but cycles should be caught by cycle-detector, so this is defensive)\n if (sorted.length !== entries.length) {\n // Return original order for entries in cycles\n const sortedSet = new Set(sorted);\n for (const entry of entries) {\n if (!sortedSet.has(entry)) {\n sorted.push(entry);\n }\n }\n }\n\n return sorted;\n}\n","import { DerivationLogicConfig } from '@ng-forge/dynamic-forms/internal';\nimport { extractExpressionDependencies, extractStringDependencies } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Extracts the union of field dependencies declared by a derivation config.\n *\n * @internal\n */\nexport function extractDependenciesFromConfig(config: DerivationLogicConfig): string[] {\n const deps = new Set<string>();\n\n if (config.dependsOn && config.dependsOn.length > 0) {\n config.dependsOn.forEach((dep) => deps.add(dep));\n } else {\n if (config.expression) {\n extractStringDependencies(config.expression).forEach((dep) => deps.add(dep));\n }\n // Inline `fn` mirrors `functionName` semantics: without explicit dependsOn,\n // assume the function may depend on anything in the form.\n if (config.functionName || (config as { fn?: unknown }).fn) {\n deps.add('*');\n }\n }\n\n if (config.condition && config.condition !== true) {\n extractExpressionDependencies(config.condition).forEach((dep) => deps.add(dep));\n }\n\n return Array.from(deps);\n}\n","import type { AsyncDerivationFunction } from '@ng-forge/dynamic-forms/internal';\nimport type { CustomFunction } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Reads the optional inline `fn` property off a derivation config.\n *\n * @internal\n */\nexport function extractInlineFn(config: object): CustomFunction | undefined {\n return (config as { fn?: CustomFunction }).fn;\n}\n\n/**\n * Reads the optional inline `asyncFn` property off a derivation config.\n *\n * @internal\n */\nexport function extractInlineAsyncFn(config: object): AsyncDerivationFunction | undefined {\n return (config as { asyncFn?: AsyncDerivationFunction }).asyncFn;\n}\n","import { DynamicFormError } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Tokens recognised by the dependency resolver. Exported so the few callers\n * that build `dependsOn` arrays programmatically (e.g. tests) don't have to\n * inline the string literal.\n */\nexport const SELF_DEPENDENCY_TOKEN = '$self';\n\n/**\n * Token resolving to the absolute path of the field's nearest parent\n * container (group or array).\n */\nexport const GROUP_DEPENDENCY_TOKEN = '$group';\n\n/**\n * Context threaded through {@link traverseFieldsWithContext} during derivation\n * collection. Tracks array + group ancestry so absolute field paths and\n * `$self` / `$group` tokens can be resolved without re-walking the tree.\n *\n * @internal\n */\nexport interface CollectionContext {\n /** Path to the enclosing array (e.g. `'items'` or `'orders.0.lineItems'`). */\n arrayPath?: string;\n /**\n * Dot-joined ancestor group keys scoped to the current array item (or\n * top-level when not inside an array). Reset at array boundaries — the\n * array placeholder path takes over, and group ancestors OUTSIDE an\n * array don't contribute to descendants' entry keys.\n */\n groupPath?: string;\n}\n\n/**\n * Builds the absolute field path for an entry whose enclosing context is\n * tracked by {@link CollectionContext}.\n *\n * @internal\n */\nexport function buildEffectiveFieldKey(fieldKey: string, context: CollectionContext): string {\n if (context.arrayPath && context.groupPath) {\n return `${context.arrayPath}.$.${context.groupPath}.${fieldKey}`;\n }\n if (context.arrayPath) {\n return `${context.arrayPath}.$.${fieldKey}`;\n }\n if (context.groupPath) {\n return `${context.groupPath}.${fieldKey}`;\n }\n return fieldKey;\n}\n\n/**\n * Resolves the absolute path of the field's nearest parent container\n * (group or array). Returns `undefined` when the field is at the form root\n * with no parent container.\n *\n * @internal\n */\nexport function buildNearestGroupPath(context: CollectionContext): string | undefined {\n if (context.arrayPath && context.groupPath) {\n return `${context.arrayPath}.$.${context.groupPath}`;\n }\n return context.arrayPath ?? context.groupPath;\n}\n\n/**\n * Substitutes `$self` / `$self.X` / `$group` / `$group.X` tokens in a\n * `dependsOn` array with their resolved absolute paths. Non-token strings\n * pass through unchanged.\n *\n * @internal\n */\nexport function resolveTokenDependencies(deps: string[], effectiveFieldKey: string, context: CollectionContext): string[] {\n return deps.map((dep) => {\n if (dep === SELF_DEPENDENCY_TOKEN) {\n return effectiveFieldKey;\n }\n if (dep.startsWith(`${SELF_DEPENDENCY_TOKEN}.`)) {\n return effectiveFieldKey + dep.slice(SELF_DEPENDENCY_TOKEN.length);\n }\n if (dep === GROUP_DEPENDENCY_TOKEN || dep.startsWith(`${GROUP_DEPENDENCY_TOKEN}.`)) {\n const groupPath = buildNearestGroupPath(context);\n if (!groupPath) {\n throw new DynamicFormError(\n `Derivation for '${effectiveFieldKey}' uses '${dep}' but the field has no parent group or array. ` +\n `'${GROUP_DEPENDENCY_TOKEN}' is only valid for fields nested inside a group or array container.`,\n );\n }\n if (dep === GROUP_DEPENDENCY_TOKEN) return groupPath;\n return groupPath + dep.slice(GROUP_DEPENDENCY_TOKEN.length);\n }\n return dep;\n });\n}\n\n/**\n * Standard `traverseFieldsWithContext` step handlers for derivation\n * collection.\n *\n * @internal\n */\nexport const CONTEXT_TRAVERSAL_OPTIONS = {\n onArrayChild: (_parent: CollectionContext, field: { key: string }) => ({ arrayPath: field.key, groupPath: undefined }),\n onGroupChild: (parent: CollectionContext, field: { key?: string }) => {\n if (!field.key) return {};\n return { groupPath: parent.groupPath ? `${parent.groupPath}.${field.key}` : field.key };\n },\n};\n","import { FieldDef } from '@ng-forge/dynamic-forms/internal';\nimport { FieldWithValidation } from '@ng-forge/dynamic-forms/internal';\nimport { DynamicFormError } from '@ng-forge/dynamic-forms/internal';\nimport { DerivationLogicConfig, hasTargetProperty, isDerivationLogicConfig } from '@ng-forge/dynamic-forms/internal';\nimport { extractStringDependencies } from '@ng-forge/dynamic-forms/internal';\nimport { PropertyDerivationCollection, PropertyDerivationEntry } from '../property-derivation/property-derivation-types';\nimport { topologicalSort } from './derivation-sorter';\nimport { DerivationCollection, DerivationEntry } from './derivation-types';\nimport { extractDependenciesFromConfig } from './extract-dependencies';\nimport { extractInlineAsyncFn, extractInlineFn } from './extract-inline-fn';\nimport { traverseFieldsWithContext } from './field-traversal';\nimport { buildEffectiveFieldKey, CollectionContext, CONTEXT_TRAVERSAL_OPTIONS, resolveTokenDependencies } from './collection-context';\n\n// Re-export the dependency tokens for back-compat with external imports.\nexport { SELF_DEPENDENCY_TOKEN, GROUP_DEPENDENCY_TOKEN } from './collection-context';\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Public collectors\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Collects all VALUE derivation entries from field definitions.\n *\n * @param fields - Array of field definitions to traverse\n * @returns Collection containing sorted derivation entries\n */\nexport function collectDerivations(fields: FieldDef<unknown>[]): DerivationCollection {\n const entries: DerivationEntry[] = [];\n\n traverseFieldsWithContext<CollectionContext>(\n fields,\n {},\n (field, context) => collectValueEntriesFromField(field, entries, context),\n CONTEXT_TRAVERSAL_OPTIONS,\n );\n\n return { entries: topologicalSort(entries) };\n}\n\n/**\n * Single traversal that emits both value derivations (sorted topologically)\n * and property derivations (no sort — they don't chain among themselves).\n *\n * @internal\n */\nexport function collectAllDerivations(fields: FieldDef<unknown>[]): {\n value: DerivationCollection;\n property: PropertyDerivationCollection;\n} {\n const valueEntries: DerivationEntry[] = [];\n const propertyEntries: PropertyDerivationEntry[] = [];\n\n traverseFieldsWithContext<CollectionContext>(\n fields,\n {},\n (field, context) => {\n collectValueEntriesFromField(field, valueEntries, context);\n collectPropertyEntriesFromField(field, propertyEntries, context);\n },\n CONTEXT_TRAVERSAL_OPTIONS,\n );\n\n return {\n value: { entries: topologicalSort(valueEntries) },\n property: { entries: propertyEntries },\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Per-field handlers\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Collects value-derivation entries (shorthand + `logic[]` without\n * `targetProperty`) from a single field.\n *\n * @internal\n */\nfunction collectValueEntriesFromField(field: FieldDef<unknown>, entries: DerivationEntry[], context: CollectionContext): void {\n const fieldKey = field.key;\n if (!fieldKey) return;\n\n const validationField = field as FieldDef<unknown> & FieldWithValidation;\n\n if (validationField.derivation) {\n entries.push(createShorthandEntry(fieldKey, validationField.derivation, context));\n }\n\n if (validationField.logic) {\n for (const logicConfig of validationField.logic) {\n if (!isDerivationLogicConfig(logicConfig)) continue;\n if (hasTargetProperty(logicConfig)) continue; // property entries handled by collectPropertyEntriesFromField\n entries.push(createValueLogicEntry(fieldKey, logicConfig, context));\n }\n }\n}\n\n/**\n * Collects property-derivation entries (`logic[]` with `targetProperty`) from\n * a single field.\n *\n * @internal\n */\nfunction collectPropertyEntriesFromField(field: FieldDef<unknown>, entries: PropertyDerivationEntry[], context: CollectionContext): void {\n const fieldKey = field.key;\n if (!fieldKey) return;\n\n const validationField = field as FieldDef<unknown> & FieldWithValidation;\n\n if (!validationField.logic) return;\n\n for (const logicConfig of validationField.logic) {\n if (!isDerivationLogicConfig(logicConfig)) continue;\n if (!hasTargetProperty(logicConfig)) continue;\n entries.push(createPropertyLogicEntry(fieldKey, logicConfig, context));\n }\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Entry builders\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Creates a value-derivation entry from a field's shorthand `derivation`\n * property.\n *\n * @internal\n */\nfunction createShorthandEntry(fieldKey: string, expression: string, context: CollectionContext): DerivationEntry {\n const effectiveFieldKey = buildEffectiveFieldKey(fieldKey, context);\n\n return {\n fieldKey: effectiveFieldKey,\n dependsOn: extractStringDependencies(expression),\n condition: true,\n expression,\n trigger: 'onChange',\n isShorthand: true,\n };\n}\n\n/**\n * Creates a value-derivation entry from a `logic[]` `DerivationLogicConfig`\n * that does NOT have `targetProperty`.\n *\n * @internal\n */\nfunction createValueLogicEntry(fieldKey: string, config: DerivationLogicConfig, context: CollectionContext): DerivationEntry {\n const effectiveFieldKey = buildEffectiveFieldKey(fieldKey, context);\n\n // Runtime guards for HTTP and async derivations.\n // Mutual exclusivity and required fields are now enforced by TypeScript (via `source` discriminant).\n // The wildcard and empty-array checks below are not expressible in the type system.\n //\n // Note: `$group` / `$group.X` tokens are intentionally allowed for HTTP and async sources even\n // though they resolve to the parent path and therefore fire on any sibling change inside that\n // group/array. Unlike `*`, the token is structurally scoped (collection throws if the field has\n // no parent container) and an explicit opt-in. Consumers that want to bound request frequency\n // can use `trigger: 'debounced'` here, or rely on debouncing inside their async function.\n if (config.source === 'http') {\n if (config.dependsOn.length === 0) {\n throw new DynamicFormError(\n `HTTP derivation for '${effectiveFieldKey}' requires explicit 'dependsOn'. ` +\n `Wildcard dependencies would trigger HTTP requests on every form change.`,\n );\n }\n if (config.dependsOn.includes('*')) {\n throw new DynamicFormError(\n `HTTP derivation for '${effectiveFieldKey}' cannot use wildcard ('*') in 'dependsOn'. ` +\n `Wildcards would trigger HTTP requests on every form change. Specify explicit field dependencies instead.`,\n );\n }\n }\n\n if (config.source === 'asyncFunction') {\n if (config.dependsOn.length === 0) {\n throw new DynamicFormError(\n `Async derivation for '${effectiveFieldKey}' requires explicit 'dependsOn'. ` +\n `Wildcard dependencies would trigger async functions on every form change.`,\n );\n }\n if (config.dependsOn.includes('*')) {\n throw new DynamicFormError(\n `Async derivation for '${effectiveFieldKey}' cannot use wildcard ('*') in 'dependsOn'. ` +\n `Wildcards would trigger async functions on every form change. Specify explicit field dependencies instead.`,\n );\n }\n }\n\n const dependsOn = resolveTokenDependencies(extractDependenciesFromConfig(config), effectiveFieldKey, context);\n const condition = config.condition ?? true;\n const trigger = config.trigger ?? 'onChange';\n const debounceMs = trigger === 'debounced' ? (config as { debounceMs?: number }).debounceMs : undefined;\n\n return {\n fieldKey: effectiveFieldKey,\n dependsOn,\n condition,\n value: config.value,\n expression: config.expression,\n functionName: config.functionName,\n fn: extractInlineFn(config),\n http: config.http,\n responseExpression: config.responseExpression,\n asyncFunctionName: config.asyncFunctionName,\n asyncFn: extractInlineAsyncFn(config),\n trigger,\n debounceMs,\n isShorthand: false,\n originalConfig: config,\n debugName: config.debugName,\n stopOnUserOverride: config.stopOnUserOverride,\n reEngageOnDependencyChange: config.reEngageOnDependencyChange,\n };\n}\n\n/**\n * Creates a property-derivation entry from a `logic[]` `DerivationLogicConfig`\n * that has `targetProperty`. Effective key uses the same array/group ancestry\n * encoding as value entries via {@link buildEffectiveFieldKey} —\n * `buildPropertyOverrideKey` in the original property collector produced the\n * same shape.\n *\n * @internal\n */\nfunction createPropertyLogicEntry(\n fieldKey: string,\n config: DerivationLogicConfig & { targetProperty: string },\n context: CollectionContext,\n): PropertyDerivationEntry {\n const effectiveFieldKey = buildEffectiveFieldKey(fieldKey, context);\n const errorLocation = `'${effectiveFieldKey}.${config.targetProperty}'`;\n\n // `targetProperty` is captured up front and `untypedConfig` is used inside\n // the source-narrowed blocks because TypeScript narrows `config` to `never`\n // once we negate a discriminant-required field (e.g. `!config.http` when\n // `source: 'http'` requires `http`).\n const untypedConfig = config as {\n source?: string;\n value?: unknown;\n expression?: unknown;\n functionName?: unknown;\n fn?: unknown;\n http?: unknown;\n responseExpression?: string;\n asyncFunctionName?: string;\n asyncFn?: unknown;\n dependsOn?: unknown;\n };\n\n // Reject configs that mix value-source fields with an explicit async source.\n if (untypedConfig.source === 'http' || untypedConfig.source === 'asyncFunction') {\n const conflictingSyncSources: string[] = [];\n if (untypedConfig.value !== undefined) conflictingSyncSources.push('value');\n if (untypedConfig.expression !== undefined) conflictingSyncSources.push('expression');\n if (untypedConfig.functionName !== undefined) conflictingSyncSources.push('functionName');\n if (untypedConfig.fn !== undefined) conflictingSyncSources.push('fn');\n if (conflictingSyncSources.length > 0) {\n throw new DynamicFormError(\n `Property derivation for ${errorLocation} sets 'source: ${JSON.stringify(untypedConfig.source)}' alongside ` +\n `sync value source(s) [${conflictingSyncSources.join(', ')}]. These are mutually exclusive — remove either ` +\n `the sync fields or the async source.`,\n );\n }\n }\n\n if (untypedConfig.source === 'http' && (untypedConfig.asyncFunctionName !== undefined || untypedConfig.asyncFn !== undefined)) {\n throw new DynamicFormError(\n `Property derivation for ${errorLocation} sets 'source: \"http\"' alongside an async function — these are mutually exclusive.`,\n );\n }\n if (untypedConfig.source === 'asyncFunction' && (untypedConfig.http !== undefined || untypedConfig.responseExpression !== undefined)) {\n throw new DynamicFormError(\n `Property derivation for ${errorLocation} sets 'source: \"asyncFunction\"' alongside HTTP fields — these are mutually exclusive.`,\n );\n }\n\n if (untypedConfig.source === 'http') {\n if (!untypedConfig.http) {\n throw new DynamicFormError(`HTTP property derivation for ${errorLocation} requires an 'http' config object.`);\n }\n if (typeof untypedConfig.responseExpression !== 'string' || untypedConfig.responseExpression.trim() === '') {\n throw new DynamicFormError(`HTTP property derivation for ${errorLocation} requires a non-empty 'responseExpression'.`);\n }\n if (!Array.isArray(untypedConfig.dependsOn) || untypedConfig.dependsOn.length === 0) {\n throw new DynamicFormError(\n `HTTP property derivation for ${errorLocation} requires explicit 'dependsOn'. ` +\n `Wildcard dependencies would trigger HTTP requests on every form change.`,\n );\n }\n if (untypedConfig.dependsOn.includes('*')) {\n throw new DynamicFormError(\n `HTTP property derivation for ${errorLocation} cannot use wildcard ('*') in 'dependsOn'. ` +\n `Wildcards would trigger HTTP requests on every form change. Specify explicit field dependencies instead.`,\n );\n }\n }\n\n if (untypedConfig.source === 'asyncFunction') {\n if (!untypedConfig.asyncFunctionName && !untypedConfig.asyncFn) {\n throw new DynamicFormError(`Async property derivation for ${errorLocation} requires either 'asyncFunctionName' or 'asyncFn'.`);\n }\n if (!Array.isArray(untypedConfig.dependsOn) || untypedConfig.dependsOn.length === 0) {\n throw new DynamicFormError(\n `Async property derivation for ${errorLocation} requires explicit 'dependsOn'. ` +\n `Wildcard dependencies would trigger async functions on every form change.`,\n );\n }\n if (untypedConfig.dependsOn.includes('*')) {\n throw new DynamicFormError(\n `Async property derivation for ${errorLocation} cannot use wildcard ('*') in 'dependsOn'. ` +\n `Wildcards would trigger async functions on every form change. Specify explicit field dependencies instead.`,\n );\n }\n }\n\n // Resolve `$self` / `$group` tokens against this entry's effective key so\n // property derivations track absolute paths the same way value derivations\n // do. The value-collector path has done this since #436; doing it here\n // closes the parity gap. `resolveTokenDependencies` throws if `$group` is\n // used on a root-level field (no parent container) — same contract as the\n // value path.\n const dependsOn = resolveTokenDependencies(extractDependenciesFromConfig(config), effectiveFieldKey, context);\n const condition = config.condition ?? true;\n const trigger = config.trigger ?? 'onChange';\n const debounceMs = trigger === 'debounced' ? (config as { debounceMs?: number }).debounceMs : undefined;\n\n return {\n fieldKey: effectiveFieldKey,\n targetProperty: config.targetProperty,\n dependsOn,\n condition,\n value: config.value,\n expression: config.expression,\n functionName: config.functionName,\n fn: extractInlineFn(config),\n http: config.http,\n responseExpression: config.responseExpression,\n asyncFunctionName: config.asyncFunctionName,\n asyncFn: extractInlineAsyncFn(config),\n trigger,\n debounceMs,\n debugName: config.debugName,\n originalConfig: config,\n };\n}\n","import { FieldDef } from '@ng-forge/dynamic-forms/internal';\nimport { collectAllDerivations } from '../derivation/derivation-collector';\nimport { PropertyDerivationCollection } from './property-derivation-types';\n\n/** Collects all property derivation entries from field definitions. */\nexport function collectPropertyDerivations(fields: FieldDef<unknown>[]): PropertyDerivationCollection {\n return collectAllDerivations(fields).property;\n}\n","import { DEV_MODE } from '../../utils/dev-mode';\nimport { CycleDetectionResult, DerivationCollection } from './derivation-types';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Node in the derivation dependency graph.\n *\n * @internal\n */\ninterface GraphNode {\n /** Field key this node represents */\n fieldKey: string;\n\n /** Set of field keys this node depends on (edges to other nodes) */\n dependsOn: Set<string>;\n\n /** Set of field keys that depend on this node (reverse edges) */\n dependedOnBy: Set<string>;\n}\n\n/**\n * State for topological sort cycle detection.\n *\n * @internal\n */\nconst enum VisitState {\n /** Node has not been visited */\n Unvisited = 0,\n /** Node is currently being processed (in the stack) */\n InProgress = 1,\n /** Node has been fully processed */\n Completed = 2,\n}\n\n/**\n * Detects cycles in the derivation dependency graph.\n *\n * @param collection - The collected derivation entries\n * @returns Result indicating whether a cycle exists and details if found\n */\nexport function detectCycles(collection: DerivationCollection): CycleDetectionResult {\n // Build directed graph from derivation entries\n const graph = buildDependencyGraph(collection);\n\n // No entries means no cycles\n if (graph.size === 0) {\n return { hasCycle: false };\n }\n\n // Build bidirectional pairs set for cycle exemption\n const bidirectionalPairs = detectBidirectionalPairs(collection);\n\n // Run DFS to detect cycles\n const result = detectCyclesWithDFS(graph, bidirectionalPairs);\n\n // Include bidirectional pairs in result for warnings\n if (bidirectionalPairs.size > 0) {\n result.bidirectionalPairs = Array.from(bidirectionalPairs);\n }\n\n return result;\n}\n\n/**\n * Detects bidirectional derivation pairs (A↔B patterns).\n *\n * @internal\n */\nfunction detectBidirectionalPairs(collection: DerivationCollection): Set<string> {\n const pairs = new Set<string>();\n\n // Build a map of fieldKey -> dependencies (other fields this derivation reads from)\n const dependencyMap = new Map<string, Set<string>>();\n for (const entry of collection.entries) {\n const deps = dependencyMap.get(entry.fieldKey) ?? new Set<string>();\n for (const dep of entry.dependsOn) {\n if (dep !== '*' && dep !== entry.fieldKey) {\n deps.add(dep);\n }\n }\n dependencyMap.set(entry.fieldKey, deps);\n }\n\n // Find bidirectional pairs: A depends on B AND B depends on A\n for (const [fieldA, depsA] of dependencyMap) {\n for (const fieldB of depsA) {\n const depsB = dependencyMap.get(fieldB);\n if (depsB?.has(fieldA)) {\n // Normalize pair key (alphabetically sorted)\n const pairKey = fieldA < fieldB ? `${fieldA}↔${fieldB}` : `${fieldB}↔${fieldA}`;\n pairs.add(pairKey);\n }\n }\n }\n\n return pairs;\n}\n\n/**\n * Checks if a cycle path represents a bidirectional pair.\n *\n * @internal\n */\nfunction isBidirectionalCycle(cyclePath: string[], bidirectionalPairs: Set<string>): boolean {\n // Bidirectional cycles are exactly: [A, B, A] (length 3 with first=last)\n if (cyclePath.length !== 3) {\n return false;\n }\n\n const [first, second, third] = cyclePath;\n if (first !== third) {\n return false;\n }\n\n // Check if this pair is in our bidirectional set\n const pairKey = first < second ? `${first}↔${second}` : `${second}↔${first}`;\n return bidirectionalPairs.has(pairKey);\n}\n\n/**\n * Builds a dependency graph from derivation entries.\n *\n * @internal\n */\nfunction buildDependencyGraph(collection: DerivationCollection): Map<string, GraphNode> {\n const graph = new Map<string, GraphNode>();\n\n // Helper to ensure a node exists\n const ensureNode = (fieldKey: string): GraphNode => {\n let node = graph.get(fieldKey);\n if (!node) {\n node = {\n fieldKey,\n dependsOn: new Set(),\n dependedOnBy: new Set(),\n };\n graph.set(fieldKey, node);\n }\n return node;\n };\n\n for (const entry of collection.entries) {\n const fieldNode = ensureNode(entry.fieldKey);\n\n // Track dependencies from the derivation expression/condition.\n // For cycle detection, edges go from dependency -> field:\n // If field A depends on field B, then when B changes, A gets updated.\n // A cycle exists when there's a circular chain of such dependencies.\n for (const dep of entry.dependsOn) {\n // Skip wildcard dependencies and self-references\n if (dep !== '*' && dep !== entry.fieldKey) {\n const depNode = ensureNode(dep);\n // The field depends on 'dep'\n fieldNode.dependsOn.add(dep);\n // 'dep' is depended on by the field\n depNode.dependedOnBy.add(entry.fieldKey);\n }\n }\n }\n\n return graph;\n}\n\n/**\n * Detects cycles using depth-first search.\n *\n * @internal\n */\nfunction detectCyclesWithDFS(graph: Map<string, GraphNode>, bidirectionalPairs: Set<string>): CycleDetectionResult {\n const visitState = new Map<string, VisitState>();\n const parent = new Map<string, string | null>();\n\n // Initialize all nodes as unvisited\n for (const fieldKey of graph.keys()) {\n visitState.set(fieldKey, VisitState.Unvisited);\n }\n\n // Process each unvisited node\n for (const fieldKey of graph.keys()) {\n if (visitState.get(fieldKey) === VisitState.Unvisited) {\n const cycleResult = dfsVisit(fieldKey, graph, visitState, parent, [], bidirectionalPairs);\n if (cycleResult) {\n return cycleResult;\n }\n }\n }\n\n return { hasCycle: false };\n}\n\n/**\n * DFS visit function that detects back edges (cycles).\n *\n * @internal\n */\nfunction dfsVisit(\n fieldKey: string,\n graph: Map<string, GraphNode>,\n visitState: Map<string, VisitState>,\n parent: Map<string, string | null>,\n path: string[],\n bidirectionalPairs: Set<string>,\n): CycleDetectionResult | null {\n visitState.set(fieldKey, VisitState.InProgress);\n path.push(fieldKey);\n\n const node = graph.get(fieldKey);\n if (!node) {\n visitState.set(fieldKey, VisitState.Completed);\n path.pop();\n return null;\n }\n\n // Check all nodes that this field's changes would trigger\n for (const targetFieldKey of node.dependedOnBy) {\n const targetState = visitState.get(targetFieldKey);\n\n if (targetState === VisitState.InProgress) {\n // Back edge found - potential cycle\n const cyclePath = extractCyclePath(path, targetFieldKey);\n\n // Allow bidirectional sync patterns (A→B→A)\n // These stabilize via equality checks at runtime\n if (isBidirectionalCycle(cyclePath, bidirectionalPairs)) {\n // Continue DFS without reporting this as a cycle\n continue;\n }\n\n return {\n hasCycle: true,\n cyclePath,\n errorMessage: formatCycleError(cyclePath),\n };\n }\n\n if (targetState === VisitState.Unvisited) {\n parent.set(targetFieldKey, fieldKey);\n const result = dfsVisit(targetFieldKey, graph, visitState, parent, path, bidirectionalPairs);\n if (result) {\n return result;\n }\n }\n }\n\n visitState.set(fieldKey, VisitState.Completed);\n path.pop();\n return null;\n}\n\n/**\n * Extracts the cycle path from the DFS path.\n *\n * @internal\n */\nfunction extractCyclePath(path: string[], cycleStart: string): string[] {\n const cycleStartIndex = path.indexOf(cycleStart);\n if (cycleStartIndex === -1) {\n // Shouldn't happen, but handle gracefully\n return [...path, cycleStart];\n }\n\n // Extract from cycle start to current position, then back to start\n const cyclePath = path.slice(cycleStartIndex);\n cyclePath.push(cycleStart); // Close the cycle\n return cyclePath;\n}\n\n/**\n * Formats a human-readable error message for a detected cycle.\n *\n * @internal\n */\nfunction formatCycleError(cyclePath: string[]): string {\n const pathStr = cyclePath.join(' -> ');\n return (\n `Derivation cycle detected: ${pathStr}\\n` +\n `This would cause an infinite loop at runtime. ` +\n `Remove one of the derivations to break the cycle.`\n );\n}\n\n/**\n * Validates a derivation collection and throws if cycles are detected.\n *\n * @param collection - The collected derivation entries to validate\n * @param logger - Optional logger for dev-mode warnings\n * @throws Error if a cycle is detected, with details about the cycle\n */\nexport function validateNoCycles(collection: DerivationCollection, logger?: Logger): void {\n const result = detectCycles(collection);\n\n if (result.hasCycle) {\n throw new Error(result.errorMessage);\n }\n\n // Warn about bidirectional patterns in dev mode\n if (DEV_MODE && result.bidirectionalPairs && result.bidirectionalPairs.length > 0 && logger) {\n logger.warn(\n 'Derivation: bidirectional derivation patterns detected. ' +\n 'These patterns stabilize via equality checks, but may oscillate with floating-point values ' +\n '(e.g., currency conversions with rounding). ' +\n 'Consider adding tolerance-based comparisons for numeric values.',\n result.bidirectionalPairs,\n );\n }\n}\n","import { HttpClient } from '@angular/common/http';\nimport { Signal, untracked } from '@angular/core';\nimport type { FieldTree } from '@angular/forms/signals';\nimport { EMPTY, Observable, debounceTime, filter, map, pairwise, startWith, switchMap } from 'rxjs';\nimport { catchError } from 'rxjs/operators';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport { getChangedKeys, isEqual } from '@ng-forge/dynamic-forms/internal';\nimport { isArrayPlaceholderPath } from '../../utils/path-utils/path-utils';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport { evaluateCondition } from '@ng-forge/dynamic-forms/internal';\nimport { ExpressionParser } from '@ng-forge/dynamic-forms/internal';\nimport { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { resolveHttpRequest } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { DerivationEntry } from './derivation-types';\nimport { DerivationLogger } from './derivation-logger.service';\nimport type { WarningTracker } from '@ng-forge/dynamic-forms/internal';\nimport { readFieldDirty, resetFieldState, applyValueToForm } from './field-value-utils';\nimport { readFieldStateInfo, createFormFieldStateMap } from '@ng-forge/dynamic-forms/internal';\nimport type { FieldTreeRecord } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Context required for creating HTTP derivation streams.\n *\n * @internal\n */\nexport interface HttpDerivationStreamContext {\n /** Signal containing the current form value */\n formValue: Signal<Record<string, unknown>>;\n\n /** Signal containing the form accessor */\n form: Signal<FieldTree<unknown>>;\n\n /** Angular HttpClient for making requests */\n httpClient: HttpClient;\n\n /** Logger for diagnostic output */\n logger: Logger;\n\n /** Signal containing the derivation logger */\n derivationLogger: Signal<DerivationLogger>;\n\n /** Lazy resolver for custom functions — called per-emission to get fresh values */\n customFunctions?: () => Record<string, CustomFunction> | undefined;\n\n /** Lazy resolver for external data — called per-emission to get fresh values */\n externalData?: () => Record<string, unknown> | undefined;\n\n /** Warning tracker to suppress duplicate missing-field warnings */\n warningTracker?: WarningTracker;\n}\n\nconst LOG_PREFIX = 'HTTP Derivation -';\nconst DEFAULT_HTTP_DEBOUNCE_MS = 300;\n\n/**\n * Creates an RxJS Observable stream that processes an HTTP derivation entry.\n *\n * @param entry - The derivation entry with HTTP configuration\n * @param formValue$ - Observable of form value changes\n * @param context - Context with HttpClient, logger, etc.\n * @returns Observable that applies HTTP-derived values to the form\n * @internal\n */\nexport function createHttpDerivationStream(\n entry: DerivationEntry,\n formValue$: Observable<Record<string, unknown>>,\n context: HttpDerivationStreamContext,\n): Observable<void> {\n // Guard: no array support for HTTP derivations\n if (isArrayPlaceholderPath(entry.fieldKey)) {\n context.logger.warn(\n `${LOG_PREFIX} HTTP derivation for array field '${entry.fieldKey}' is not supported. ` +\n `Array HTTP derivations will be supported in a future release.`,\n );\n return EMPTY;\n }\n\n if (!entry.http || !entry.responseExpression) {\n return EMPTY;\n }\n\n // Capture after the guard — TS narrows this to `string` here\n const responseExpression = entry.responseExpression;\n const debounceMs = entry.debounceMs ?? DEFAULT_HTTP_DEBOUNCE_MS;\n\n return formValue$.pipe(\n startWith(null as Record<string, unknown> | null),\n pairwise(),\n map(([previous, current]) => ({\n current: current!,\n changedFields: getChangedKeys(previous, current!),\n })),\n // Only proceed when a dependency in entry.dependsOn changed\n filter(({ changedFields }) => {\n if (changedFields.size === 0) return false;\n return entry.dependsOn.some((dep) => changedFields.has(dep));\n }),\n debounceTime(debounceMs),\n switchMap(({ current, changedFields }) => {\n return new Observable<void>((subscriber) => {\n const formAccessor = untracked(() => context.form());\n\n // Check stopOnUserOverride — skip if the user has manually edited the target field\n if (entry.stopOnUserOverride) {\n const isDirty = readFieldDirty(formAccessor, entry.fieldKey);\n if (isDirty) {\n // Re-engage: if a dependency changed, clear dirty state so derivation resumes\n if (entry.reEngageOnDependencyChange && changedFields.size > 0) {\n if (entry.dependsOn.some((dep) => changedFields.has(dep))) {\n resetFieldState(formAccessor, entry.fieldKey);\n // Fall through — proceed with the HTTP request\n } else {\n // Dependency didn't change, still skip\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'user-override',\n });\n subscriber.complete();\n return;\n }\n } else {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'user-override',\n });\n subscriber.complete();\n return;\n }\n }\n }\n\n // Resolve lazy context values per-emission\n const customFunctions = context.customFunctions?.();\n const externalData = context.externalData?.();\n\n // Build evaluation context for condition check and request resolution\n const fieldValue = getNestedValue(current, entry.fieldKey);\n const fieldAccessor = (formAccessor as FieldTreeRecord)[entry.fieldKey];\n const fieldState = fieldAccessor ? readFieldStateInfo(fieldAccessor, false) : undefined;\n\n const evalContext: EvaluationContext = {\n fieldValue,\n formValue: current,\n fieldPath: entry.fieldKey,\n customFunctions,\n externalData,\n logger: context.logger,\n fieldState,\n formFieldState: createFormFieldStateMap(formAccessor, false),\n };\n\n // Evaluate condition — skip if false\n if (entry.condition !== true) {\n const conditionMet = typeof entry.condition === 'boolean' ? entry.condition : evaluateCondition(entry.condition, evalContext);\n if (!conditionMet) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'condition-false',\n });\n subscriber.complete();\n return;\n }\n }\n\n // Returns null when a path param is undefined — suppress the request\n const resolvedRequest = resolveHttpRequest(entry.http!, evalContext);\n\n if (!resolvedRequest) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'condition-false',\n });\n subscriber.complete();\n return;\n }\n\n // Cancellation is handled declaratively by the orchestrator's outer\n // switchMap: when the entry set changes, the outer stream unsubscribes\n // from this inner observable, the cleanup callback below runs, and\n // the in-flight HTTP request is cancelled.\n const method = (resolvedRequest.method ?? 'GET').toUpperCase();\n const httpSub = context.httpClient\n .request(method, resolvedRequest.url, {\n body: resolvedRequest.body,\n headers: resolvedRequest.headers as Record<string, string>,\n })\n .subscribe({\n next: (response) => {\n try {\n // Extract value from response using responseExpression\n const newValue = ExpressionParser.evaluate(responseExpression, { response });\n\n // Compare with current value — skip if unchanged\n const currentFormValue = untracked(() => context.formValue());\n const currentValue = getNestedValue(currentFormValue, entry.fieldKey);\n\n if (isEqual(currentValue, newValue)) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'value-unchanged',\n });\n subscriber.complete();\n return;\n }\n\n // Re-read dirty state at apply-time: the user may have edited the field\n // after the request started but before it resolved. Mirrors the\n // fire-time stopOnUserOverride guard in the switchMap above.\n const currentForm = untracked(() => context.form());\n if (entry.stopOnUserOverride && readFieldDirty(currentForm, entry.fieldKey)) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'user-override',\n });\n subscriber.complete();\n return;\n }\n\n // Apply the value to the form\n applyValueToForm(entry.fieldKey, newValue, currentForm, context.logger, context.warningTracker);\n\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'applied',\n previousValue: currentValue,\n newValue,\n });\n subscriber.complete();\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Failed to process response for '${entry.fieldKey}': ${message}`);\n subscriber.complete();\n }\n },\n error: (error) => {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} HTTP request failed for '${entry.fieldKey}': ${message}`);\n subscriber.complete();\n },\n });\n\n // Cleanup: RxJS unsubscription cancels the in-flight HTTP request.\n // Angular's HttpClient uses an internal AbortController when operating with\n // the fetch backend, so unsubscribing is sufficient for network cancellation.\n return () => {\n httpSub.unsubscribe();\n };\n });\n }),\n catchError((error) => {\n // Safety net — should not normally be reached since inner errors are caught\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Unexpected stream error for '${entry.fieldKey}': ${message}`);\n return EMPTY;\n }),\n );\n}\n","import { Signal, untracked } from '@angular/core';\nimport type { FieldTree } from '@angular/forms/signals';\nimport { EMPTY, from, Observable, debounceTime, filter, map, pairwise, startWith, switchMap } from 'rxjs';\nimport { catchError } from 'rxjs/operators';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport { getChangedKeys, isEqual } from '@ng-forge/dynamic-forms/internal';\nimport { isArrayPlaceholderPath } from '../../utils/path-utils/path-utils';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport type { AsyncDerivationFunction } from '@ng-forge/dynamic-forms/internal';\nimport { evaluateCondition } from '@ng-forge/dynamic-forms/internal';\nimport { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { DerivationEntry } from './derivation-types';\nimport { DerivationLogger } from './derivation-logger.service';\nimport type { WarningTracker } from '@ng-forge/dynamic-forms/internal';\nimport { readFieldDirty, resetFieldState, applyValueToForm } from './field-value-utils';\nimport { readFieldStateInfo, createFormFieldStateMap } from '@ng-forge/dynamic-forms/internal';\nimport type { FieldTreeRecord } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Context required for creating async derivation streams.\n *\n * @internal\n */\nexport interface AsyncDerivationStreamContext {\n /** Signal containing the current form value */\n formValue: Signal<Record<string, unknown>>;\n\n /** Signal containing the form accessor */\n form: Signal<FieldTree<unknown>>;\n\n /** Logger for diagnostic output */\n logger: Logger;\n\n /** Signal containing the derivation logger */\n derivationLogger: Signal<DerivationLogger>;\n\n /** Lazy resolver for custom functions — called per-emission to get fresh values */\n customFunctions?: () => Record<string, CustomFunction> | undefined;\n\n /** Lazy resolver for async derivation functions — called per-emission to get fresh values */\n asyncDerivationFunctions?: () => Record<string, AsyncDerivationFunction> | undefined;\n\n /** Lazy resolver for external data — called per-emission to get fresh values */\n externalData?: () => Record<string, unknown> | undefined;\n\n /** Warning tracker to suppress duplicate missing-field warnings */\n warningTracker?: WarningTracker;\n}\n\nconst LOG_PREFIX = 'Async Derivation -';\nconst DEFAULT_ASYNC_DEBOUNCE_MS = 300;\n\n/**\n * Creates an RxJS Observable stream that processes an async derivation entry.\n *\n * @param entry - The derivation entry with asyncFunctionName\n * @param formValue$ - Observable of form value changes\n * @param context - Context with logger, etc.\n * @returns Observable that applies async-derived values to the form\n * @internal\n */\nexport function createAsyncDerivationStream(\n entry: DerivationEntry,\n formValue$: Observable<Record<string, unknown>>,\n context: AsyncDerivationStreamContext,\n): Observable<void> {\n // Guard: no array support for async derivations\n if (isArrayPlaceholderPath(entry.fieldKey)) {\n context.logger.warn(\n `${LOG_PREFIX} Async derivation for array field '${entry.fieldKey}' is not supported. ` +\n `Array async derivations will be supported in a future release.`,\n );\n return EMPTY;\n }\n\n if (!entry.asyncFunctionName && !entry.asyncFn) {\n context.logger.warn(\n `${LOG_PREFIX} Derivation for '${entry.fieldKey}' has neither \"asyncFunctionName\" nor \"asyncFn\". ` +\n `One of the two is required for source: 'asyncFunction'.`,\n );\n return EMPTY;\n }\n\n if (entry.asyncFn && entry.asyncFunctionName) {\n context.logger.warn(\n `${LOG_PREFIX} Both \"asyncFn\" and \"asyncFunctionName\" are set on derivation for '${entry.fieldKey}'. ` +\n `Inline \"asyncFn\" takes precedence; \"asyncFunctionName\" is ignored.`,\n );\n }\n\n const inlineAsyncFn = entry.asyncFn;\n const asyncFunctionName = entry.asyncFunctionName;\n const debounceMs = entry.debounceMs ?? DEFAULT_ASYNC_DEBOUNCE_MS;\n\n // startWith(null) → pairwise() causes all fields to appear \"changed\" on the\n // first emission (null vs actual form value), so every dependsOn field matches.\n // This intentionally fires the async derivation on initial form load — same\n // pattern as http-derivation-stream.ts.\n return formValue$.pipe(\n startWith(null as Record<string, unknown> | null),\n pairwise(),\n map(([previous, current]) => ({\n current: current!,\n changedFields: getChangedKeys(previous, current!),\n })),\n // Only proceed when a dependency in entry.dependsOn changed\n filter(({ changedFields }) => {\n if (changedFields.size === 0) return false;\n return entry.dependsOn.some((dep) => changedFields.has(dep));\n }),\n debounceTime(debounceMs),\n switchMap(({ current, changedFields }) => {\n return new Observable<void>((subscriber) => {\n const formAccessor = untracked(() => context.form());\n\n // Check stopOnUserOverride — skip if the user has manually edited the target field\n if (entry.stopOnUserOverride) {\n const isDirty = readFieldDirty(formAccessor, entry.fieldKey);\n if (isDirty) {\n // Re-engage: the outer filter already guarantees changedFields is non-empty\n // and that a dependsOn field changed, so only check reEngageOnDependencyChange.\n if (entry.reEngageOnDependencyChange) {\n resetFieldState(formAccessor, entry.fieldKey);\n // Fall through — proceed with the async call\n } else {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'user-override',\n });\n subscriber.complete();\n return;\n }\n }\n }\n\n // Resolve lazy context values per-emission\n const customFunctions = context.customFunctions?.();\n const asyncDerivationFunctions = context.asyncDerivationFunctions?.();\n const externalData = context.externalData?.();\n\n // Inline `asyncFn` wins; otherwise look up the registered function.\n const asyncFn = inlineAsyncFn ?? (asyncFunctionName ? asyncDerivationFunctions?.[asyncFunctionName] : undefined);\n if (!asyncFn) {\n context.logger.warn(\n `${LOG_PREFIX} Async derivation function '${asyncFunctionName}' not found for field '${entry.fieldKey}'. ` +\n `Register it in customFnConfig.asyncDerivations.`,\n );\n subscriber.complete();\n return;\n }\n\n // Build evaluation context for condition check\n const fieldValue = getNestedValue(current, entry.fieldKey);\n const fieldAccessor = (formAccessor as FieldTreeRecord)[entry.fieldKey];\n const fieldState = fieldAccessor ? readFieldStateInfo(fieldAccessor, false) : undefined;\n\n const evalContext: EvaluationContext = {\n fieldValue,\n formValue: current,\n fieldPath: entry.fieldKey,\n customFunctions,\n externalData,\n logger: context.logger,\n fieldState,\n formFieldState: createFormFieldStateMap(formAccessor, false),\n };\n\n // Evaluate condition — skip if false\n if (entry.condition !== true) {\n const conditionMet = typeof entry.condition === 'boolean' ? entry.condition : evaluateCondition(entry.condition, evalContext);\n if (!conditionMet) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'condition-false',\n });\n subscriber.complete();\n return;\n }\n }\n\n // Call the async function (normalize Promise → Observable)\n const result$ = from(asyncFn(evalContext));\n\n const asyncSub = result$.subscribe({\n next: (newValue) => {\n try {\n // Compare with current value — skip if unchanged\n const currentFormValue = untracked(() => context.formValue());\n const currentValue = getNestedValue(currentFormValue, entry.fieldKey);\n\n if (isEqual(currentValue, newValue)) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'value-unchanged',\n });\n subscriber.complete();\n return;\n }\n\n // Re-read dirty state at apply-time: the user may have edited the field\n // after the async call started but before it resolved. Mirrors the\n // fire-time stopOnUserOverride guard in the switchMap above.\n const currentForm = untracked(() => context.form());\n if (entry.stopOnUserOverride && readFieldDirty(currentForm, entry.fieldKey)) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'user-override',\n });\n subscriber.complete();\n return;\n }\n\n // Apply the value to the form\n applyValueToForm(entry.fieldKey, newValue, currentForm, context.logger, context.warningTracker);\n\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'applied',\n previousValue: currentValue,\n newValue,\n });\n subscriber.complete();\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Failed to process result for '${entry.fieldKey}': ${message}`);\n subscriber.complete();\n }\n },\n error: (error) => {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Async function failed for '${entry.fieldKey}': ${message}`);\n subscriber.complete();\n },\n complete: () => {\n // Handles Observables that complete without emitting (e.g. EMPTY),\n // preventing the inner Observable from staying open indefinitely.\n subscriber.complete();\n },\n });\n\n // Cleanup subscription on unsubscribe (switchMap cancellation)\n return () => {\n asyncSub.unsubscribe();\n };\n });\n }),\n catchError((error) => {\n // Safety net — should not normally be reached since inner errors are caught\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Unexpected stream error for '${entry.fieldKey}': ${message}`);\n return EMPTY;\n }),\n );\n}\n","import { isSignal, Signal, untracked } from '@angular/core';\n\n/**\n * Resolves a record of external-data signals to their current values without\n * establishing reactive dependencies.\n *\n * @internal\n */\nexport function resolveExternalData(\n externalDataSignal: Signal<Record<string, Signal<unknown>> | undefined> | undefined,\n): Record<string, unknown> | undefined {\n const externalDataRecord = untracked(() => externalDataSignal?.());\n\n if (!externalDataRecord) return undefined;\n\n const resolved: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(externalDataRecord)) {\n if (isSignal(value)) {\n resolved[key] = untracked(() => value());\n } else {\n resolved[key] = value;\n }\n }\n\n return resolved;\n}\n","import { DEFAULT_DEBOUNCE_MS } from '../../utils/debounce/debounce';\nimport { BaseDerivationEntry } from './derivation-entry-base';\n\n/**\n * Returns the unique set of debounce periods declared by `entries` whose\n * `trigger === 'debounced'`. Entries without an explicit `debounceMs` use\n * {@link DEFAULT_DEBOUNCE_MS}.\n *\n * @internal\n */\nexport function getDebouncePeriods(entries: ReadonlyArray<Pick<BaseDerivationEntry, 'trigger' | 'debounceMs'>>): number[] {\n const periods = new Set<number>();\n for (const entry of entries) {\n if (entry.trigger === 'debounced') {\n periods.add(entry.debounceMs ?? DEFAULT_DEBOUNCE_MS);\n }\n }\n return Array.from(periods);\n}\n\n/**\n * Filters `entries` down to those whose `trigger === 'debounced'` and whose\n * effective `debounceMs` (defaulting to {@link DEFAULT_DEBOUNCE_MS}) equals `period`.\n *\n * @internal\n */\nexport function filterEntriesByDebouncePeriod<TEntry extends Pick<BaseDerivationEntry, 'trigger' | 'debounceMs'>>(\n entries: ReadonlyArray<TEntry>,\n period: number,\n): TEntry[] {\n return entries.filter((entry) => entry.trigger === 'debounced' && (entry.debounceMs ?? DEFAULT_DEBOUNCE_MS) === period);\n}\n","import { EMPTY, Observable, distinctUntilChanged, map, switchMap, merge } from 'rxjs';\n\n/**\n * Compares two entry lists as multisets of content signatures. Order-insensitive\n * — entries reordered with the same content compare equal, so topological-sort\n * reorderings triggered by unrelated entries don't churn downstream stream\n * lifecycles.\n *\n * @internal\n */\nexport function entrySetsEqual<T>(prev: T[], next: T[], sig: (item: T) => string): boolean {\n if (prev.length !== next.length) return false;\n\n const counts = new Map<string, number>();\n for (const item of prev) {\n const key = sig(item);\n counts.set(key, (counts.get(key) ?? 0) + 1);\n }\n\n for (const item of next) {\n const key = sig(item);\n const remaining = counts.get(key);\n if (!remaining) return false;\n if (remaining === 1) counts.delete(key);\n else counts.set(key, remaining - 1);\n }\n\n return counts.size === 0;\n}\n\n/**\n * Configuration for {@link buildEntryStreamPipeline}.\n *\n * @internal\n */\nexport interface EntryStreamPipelineConfig<TCollection, TEntry> {\n /** Source of collection updates (typically a `toObservable(...computed)`). */\n collection$: Observable<TCollection | null>;\n\n /** Extracts the subset of entries this pipeline cares about. */\n selectEntries: (collection: TCollection | null) => TEntry[];\n\n /** Content-addressed identity for each entry; consumed by {@link entrySetsEqual}. */\n entrySignature: (entry: TEntry) => string;\n\n /**\n * Builds an Observable for an entry. The returned stream's lifecycle is\n * owned by the outer pipeline — when the entry set changes, the previous\n * merged inner stream is unsubscribed (cancelling in-flight work) and a\n * fresh batch is created from the new entries.\n */\n createStream: (entry: TEntry) => Observable<unknown> | null;\n}\n\n/**\n * Declarative pipeline shared by both derivation orchestrators:\n *\n * @internal\n */\nexport function buildEntryStreamPipeline<TCollection, TEntry>(config: EntryStreamPipelineConfig<TCollection, TEntry>): Observable<unknown> {\n return config.collection$.pipe(\n map((collection) => config.selectEntries(collection)),\n distinctUntilChanged((prev, next) => entrySetsEqual(prev, next, config.entrySignature)),\n switchMap((entries) => {\n if (entries.length === 0) return EMPTY;\n const streams = entries.map((entry) => config.createStream(entry)).filter((stream): stream is Observable<unknown> => stream !== null);\n if (streams.length === 0) return EMPTY;\n return merge(...streams);\n }),\n );\n}\n","import { DestroyRef, Injector, Signal, untracked } from '@angular/core';\nimport { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';\nimport {\n auditTime,\n catchError,\n combineLatestWith,\n debounceTime,\n EMPTY,\n exhaustMap,\n filter,\n map,\n merge,\n of,\n Observable,\n pairwise,\n queueScheduler,\n scheduled,\n startWith,\n switchMap,\n timer,\n} from 'rxjs';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { DEFAULT_DEBOUNCE_MS } from '../../utils/debounce/debounce';\nimport { getChangedKeys } from '@ng-forge/dynamic-forms/internal';\nimport { getDebouncePeriods } from './debounce-period-utils';\nimport { BaseDerivationEntry } from './derivation-entry-base';\nimport { buildEntryStreamPipeline } from './entry-set-utils';\n\n/**\n * Minimum shape a derivation collection must expose for the reactive setup\n * helpers to work with both value-derivation and property-derivation pipelines.\n * Both pipelines' collections satisfy this — they each carry an `entries`\n * array of {@link BaseDerivationEntry}-conforming records.\n *\n * @internal\n */\nexport interface ReactiveDerivationCollection {\n entries: ReadonlyArray<Pick<BaseDerivationEntry, 'trigger' | 'debounceMs'>>;\n}\n\n/**\n * Options shared by both `setupOnChangeStream` and `setupDebouncedStream`.\n * Centralizes the boilerplate of subscribing through `toObservable` + the\n * orchestrator's destroyRef + the canonical error-logging label so neither\n * orchestrator has to duplicate it.\n *\n * @internal\n */\ninterface BaseReactiveStreamOptions<TCollection extends ReactiveDerivationCollection> {\n /** Injector for `toObservable` calls. Must be the orchestrator's `inject(Injector)`. */\n injector: Injector;\n /** DestroyRef tied to the orchestrator instance — auto-cleans the subscription on form teardown. */\n destroyRef: DestroyRef;\n /** Logger used for stream-level errors (synchronous throws inside operators). */\n logger: Logger;\n /** Human-readable label inserted into the error log, e.g. 'Derivation onChange stream error'. */\n errorLabel: string;\n /** Signal exposing the orchestrator's collected derivations (or null when there are none). */\n collectionSignal: Signal<TCollection | null>;\n /** Signal exposing the live form value. */\n formValueSignal: Signal<Record<string, unknown>>;\n}\n\n/**\n * Options for the **onChange** reactive stream.\n *\n * @internal\n */\nexport interface OnChangeStreamOptions<TCollection extends ReactiveDerivationCollection> extends BaseReactiveStreamOptions<TCollection> {\n applyOnChange: (collection: TCollection, changedFields: Set<string>) => void;\n additionalAwakeners?: Signal<unknown>[];\n}\n\n/**\n * Subscribe to the onChange reactive stream and route emissions through\n * `applyOnChange`. The pipeline:\n *\n * @internal\n */\nexport function setupOnChangeStream<TCollection extends ReactiveDerivationCollection>(opts: OnChangeStreamOptions<TCollection>): void {\n const collection$ = toObservable(opts.collectionSignal, { injector: opts.injector });\n const formValue$ = toObservable(opts.formValueSignal, { injector: opts.injector });\n const extra$ = opts.additionalAwakeners?.length ? opts.additionalAwakeners.map((s) => toObservable(s, { injector: opts.injector })) : [];\n\n collection$\n .pipe(\n filter((collection): collection is TCollection => collection !== null),\n combineLatestWith(formValue$, ...extra$),\n auditTime(0),\n startWith(null as readonly unknown[] | null),\n pairwise(),\n filter((pair): pair is [readonly unknown[] | null, readonly unknown[]] => pair[1] !== null),\n map(([previous, current]) => ({\n collection: current[0] as TCollection,\n changedFields: getChangedKeys(\n (previous?.[1] as Record<string, unknown> | undefined) ?? null,\n current[1] as Record<string, unknown>,\n ),\n })),\n exhaustMap(({ collection, changedFields }) => {\n opts.applyOnChange(collection, changedFields);\n return scheduled([null], queueScheduler);\n }),\n takeUntilDestroyed(opts.destroyRef),\n )\n .subscribe({\n error: (err) => opts.logger.error(opts.errorLabel, err),\n });\n}\n\n/**\n * Options for the **debounced** reactive stream.\n *\n * @internal\n */\nexport interface DebouncedStreamOptions<TCollection extends ReactiveDerivationCollection> extends BaseReactiveStreamOptions<TCollection> {\n applyDebouncedForPeriod: (debounceMs: number, collection: TCollection, changedFields: Set<string>) => void;\n}\n\n/**\n * Subscribe to the debounced reactive stream and route emissions through\n * `applyDebouncedForPeriod`. See {@link DebouncedStreamOptions} for stream\n * shape; uses `switchMap` (not `exhaustMap`) so newer changes cancel\n * older still-debouncing work — we want only the latest debounced value\n * applied per period.\n *\n * @internal\n */\nexport function setupDebouncedStream<TCollection extends ReactiveDerivationCollection>(opts: DebouncedStreamOptions<TCollection>): void {\n toObservable(opts.formValueSignal, { injector: opts.injector })\n .pipe(\n debounceTime(DEFAULT_DEBOUNCE_MS),\n startWith(null as Record<string, unknown> | null),\n pairwise(),\n filter((pair): pair is [Record<string, unknown> | null, Record<string, unknown>] => pair[1] !== null),\n map(([previous, current]) => ({ current, changedFields: getChangedKeys(previous, current) })),\n filter(({ changedFields }) => changedFields.size > 0),\n switchMap(({ changedFields }) => {\n const collection = untracked(() => opts.collectionSignal());\n if (!collection) return of(null);\n\n const debouncePeriods = getDebouncePeriods(collection.entries);\n if (debouncePeriods.length === 0) return of(null);\n\n const periodStreams: Observable<unknown>[] = debouncePeriods.map((debounceMs) => {\n const additionalWait = Math.max(0, debounceMs - DEFAULT_DEBOUNCE_MS);\n if (additionalWait === 0) {\n return of(null).pipe(\n map(() => {\n opts.applyDebouncedForPeriod(debounceMs, collection, changedFields);\n }),\n );\n }\n return timer(additionalWait).pipe(\n map(() => {\n // Re-read collection because additional wait may have elapsed\n const fresh = untracked(() => opts.collectionSignal()) ?? collection;\n opts.applyDebouncedForPeriod(debounceMs, fresh, changedFields);\n }),\n );\n });\n\n return merge(...periodStreams);\n }),\n takeUntilDestroyed(opts.destroyRef),\n )\n .subscribe({\n error: (err) => opts.logger.error(opts.errorLabel, err),\n });\n}\n\n/**\n * Options for setting up an entry-keyed async stream (HTTP derivations or\n * registered async-function derivations). Both orchestrators previously\n * hand-rolled this pattern: subscribe to the collection signal via\n * {@link buildEntryStreamPipeline}, filter to entries of the relevant kind,\n * key each emission by a stable signature so unrelated topological reorderings\n * don't churn subscriptions, and per-entry call the caller's `createStream`.\n *\n * @internal\n */\nexport interface EntryAsyncStreamOptions<TCollection extends ReactiveDerivationCollection, TEntry> {\n injector: Injector;\n destroyRef: DestroyRef;\n logger: Logger;\n /** Human-readable label included in outer-pipeline error logs. */\n errorLabel: string;\n collectionSignal: Signal<TCollection | null>;\n /** Return only the entries this stream cares about (HTTP-bearing, async-bearing, …). */\n selectEntries: (collection: TCollection | null) => TEntry[];\n /** Stable identity for an entry. Used by `entrySetsEqual` to skip rebuild when the set is unchanged. */\n entrySignature: (entry: TEntry) => string;\n /** Build the inner per-entry stream. Returning `null` suppresses the entry (e.g., HttpClient missing). */\n createStream: (entry: TEntry) => Observable<unknown> | null;\n}\n\n/**\n * Subscribe to an entry-keyed async stream. Both HTTP and async-function\n * variants of both pipelines share this exact wrapping; only the\n * caller-supplied callbacks change.\n *\n * @internal\n */\nexport function setupEntryAsyncStream<TCollection extends ReactiveDerivationCollection, TEntry>(\n opts: EntryAsyncStreamOptions<TCollection, TEntry>,\n): void {\n const collection$ = toObservable(opts.collectionSignal, { injector: opts.injector });\n\n buildEntryStreamPipeline<TCollection, TEntry>({\n collection$,\n selectEntries: opts.selectEntries,\n entrySignature: opts.entrySignature,\n createStream: opts.createStream,\n })\n .pipe(\n catchError((err) => {\n opts.logger.error(opts.errorLabel, err);\n return EMPTY;\n }),\n takeUntilDestroyed(opts.destroyRef),\n )\n .subscribe();\n}\n","import { DerivationEntry } from '../derivation/derivation-types';\n\n/** Entry representing a collected property derivation from field definitions. */\nexport type PropertyDerivationEntry = DerivationEntry & { targetProperty: string };\n\n/** Collection of all property derivation entries from a form's field definitions. */\nexport interface PropertyDerivationCollection {\n entries: PropertyDerivationEntry[];\n}\n\n/**\n * Creates an empty property derivation collection.\n *\n * @returns Empty collection\n * @internal\n */\nexport function createEmptyPropertyDerivationCollection(): PropertyDerivationCollection {\n return { entries: [] };\n}\n\n/**\n * Returns true when the entry's value source is async (HTTP or async function)\n * and therefore must be processed by a dedicated stream subscription rather\n * than the synchronous applicator pipeline.\n *\n * @internal\n */\nexport function isAsyncPropertyDerivationEntry(entry: PropertyDerivationEntry): boolean {\n return Boolean(entry.http) || Boolean(entry.asyncFunctionName) || Boolean(entry.asyncFn);\n}\n","import { Signal, untracked } from '@angular/core';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport { ConditionalExpression } from '@ng-forge/dynamic-forms/internal';\n\nimport { parseArrayPath, resolveArrayPath, isArrayPlaceholderPath } from '../../utils/path-utils/path-utils';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport { evaluateCondition } from '@ng-forge/dynamic-forms/internal';\nimport { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport type { WarningTracker } from '@ng-forge/dynamic-forms/internal';\nimport { computeValueFromEntry } from '../derivation/compute-derived-value';\nimport { getParentPathInScope, resolveArrayItemScope } from '../derivation/evaluation-scope';\nimport { isAsyncPropertyDerivationEntry, PropertyDerivationCollection, PropertyDerivationEntry } from './property-derivation-types';\nimport { PropertyOverrideStore } from './property-override-store';\n\n/** Context required for applying property derivations. */\nexport interface PropertyDerivationApplicatorContext {\n /** The current form value (signal) */\n formValue: Signal<Record<string, unknown>>;\n\n /** The property override store to write to */\n store: PropertyOverrideStore;\n\n /** Registered derivation functions (used for property derivations with `functionName`) */\n derivationFunctions?: Record<string, CustomFunction>;\n\n /** Custom functions for expression evaluation */\n customFunctions?: Record<string, CustomFunction>;\n\n /** External data resolved from form config signals */\n externalData?: Record<string, unknown>;\n\n /** Logger for diagnostic output */\n logger: Logger;\n\n /** Warning tracker to prevent log spam */\n warningTracker?: WarningTracker;\n}\n\n/** Result of property derivation processing. */\nexport interface PropertyDerivationProcessingResult {\n /** Number of property derivations successfully applied */\n appliedCount: number;\n /** Number of property derivations skipped */\n skippedCount: number;\n /** Number of property derivations that failed */\n errorCount: number;\n}\n\n/** @internal */\nconst ERROR_PREFIX = '[PropertyDerivation]';\n\n/**\n * Applies all property derivation entries from a collection.\n *\n * @param collection - The collected property derivation entries\n * @param context - Context for applying property derivations\n * @param changedFields - Optional set of changed field keys for filtering\n * @returns Result of the processing\n */\nexport function applyPropertyDerivations(\n collection: PropertyDerivationCollection,\n context: PropertyDerivationApplicatorContext,\n changedFields?: Set<string>,\n): PropertyDerivationProcessingResult {\n let appliedCount = 0;\n let skippedCount = 0;\n let errorCount = 0;\n\n const baseEntries = changedFields ? getEntriesForChangedFields(collection.entries, changedFields) : collection.entries;\n // Async entries (HTTP / asyncFn / asyncFunctionName) are processed by dedicated\n // stream subscriptions in the orchestrator, not by this synchronous applicator.\n const entriesToProcess = baseEntries.filter((entry) => !isAsyncPropertyDerivationEntry(entry));\n\n for (const entry of entriesToProcess) {\n try {\n const applied = tryApplyPropertyDerivation(entry, context);\n if (applied) {\n appliedCount++;\n } else {\n skippedCount++;\n }\n } catch (error) {\n errorCount++;\n const errorMessage = error instanceof Error ? error.message : String(error);\n context.logger.error(\n `${ERROR_PREFIX} Failed to process property derivation (field: ${entry.fieldKey}, property: ${entry.targetProperty}): ${errorMessage}`,\n );\n }\n }\n\n return { appliedCount, skippedCount, errorCount };\n}\n\n/**\n * Applies property derivations filtered by trigger type.\n *\n * @param collection - The collected property derivation entries\n * @param trigger - The trigger type to filter by\n * @param context - Context for applying property derivations\n * @param changedFields - Optional set of changed field keys for filtering\n * @returns Result of the processing\n */\nexport function applyPropertyDerivationsForTrigger(\n collection: PropertyDerivationCollection,\n trigger: 'onChange' | 'debounced',\n context: PropertyDerivationApplicatorContext,\n changedFields?: Set<string>,\n): PropertyDerivationProcessingResult {\n const filteredEntries = collection.entries.filter((entry) => {\n if (trigger === 'onChange') {\n return !entry.trigger || entry.trigger === 'onChange';\n }\n return entry.trigger === trigger;\n });\n\n const filteredCollection: PropertyDerivationCollection = { entries: filteredEntries };\n return applyPropertyDerivations(filteredCollection, context, changedFields);\n}\n\n/**\n * Filters entries based on changed fields.\n *\n * @internal\n */\nfunction getEntriesForChangedFields(entries: PropertyDerivationEntry[], changedFields: Set<string>): PropertyDerivationEntry[] {\n return entries.filter((entry) => {\n if (entry.dependsOn.includes('*')) return true;\n return entry.dependsOn.some((dep) => changedFields.has(dep));\n });\n}\n\n/**\n * Attempts to apply a single property derivation entry.\n *\n * @returns true if the override was applied, false if skipped\n * @internal\n */\nfunction tryApplyPropertyDerivation(entry: PropertyDerivationEntry, context: PropertyDerivationApplicatorContext): boolean {\n // Handle array field derivations\n if (isArrayPlaceholderPath(entry.fieldKey)) {\n return tryApplyArrayPropertyDerivation(entry, context);\n }\n\n const formValue = untracked(() => context.formValue());\n const evalContext = createEvaluationContext(entry, formValue, context);\n\n // Evaluate condition\n if (!evaluatePropertyCondition(entry.condition, evalContext)) {\n return false;\n }\n\n // Compute the derived property value\n const newValue = computePropertyValue(entry, evalContext, context);\n\n // Write to the store\n context.store.setOverride(entry.fieldKey, entry.targetProperty, newValue);\n return true;\n}\n\n/**\n * Handles array field property derivations with '$' placeholder.\n *\n * @internal\n */\nfunction tryApplyArrayPropertyDerivation(entry: PropertyDerivationEntry, context: PropertyDerivationApplicatorContext): boolean {\n const formValue = untracked(() => context.formValue());\n\n const pathInfo = parseArrayPath(entry.fieldKey);\n if (!pathInfo.isArrayPath) {\n return false;\n }\n\n const { arrayPath } = pathInfo;\n const arrayValue = getNestedValue(formValue, arrayPath);\n if (!Array.isArray(arrayValue)) {\n return false;\n }\n\n let appliedAny = false;\n\n for (let i = 0; i < arrayValue.length; i++) {\n const resolvedPath = resolveArrayPath(entry.fieldKey, i);\n const arrayItem = arrayValue[i] as Record<string, unknown>;\n const evalContext = createArrayItemEvaluationContext(entry, arrayItem, formValue, i, arrayPath, context);\n\n if (!evaluatePropertyCondition(entry.condition, evalContext)) {\n continue;\n }\n\n try {\n const newValue = computePropertyValue(entry, evalContext, context);\n context.store.setOverride(resolvedPath, entry.targetProperty, newValue);\n appliedAny = true;\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n context.logger.error(\n `${ERROR_PREFIX} Failed to compute property derivation (field: ${entry.fieldKey}, property: ${entry.targetProperty}, index: ${i}): ${errorMessage}`,\n );\n }\n }\n\n return appliedAny;\n}\n\n/**\n * Creates an evaluation context for property derivation processing.\n *\n * @internal\n */\nfunction createEvaluationContext(\n entry: PropertyDerivationEntry,\n formValue: Record<string, unknown>,\n context: PropertyDerivationApplicatorContext,\n): EvaluationContext {\n const fieldValue = getNestedValue(formValue, entry.fieldKey);\n\n // Parent group value: the absolute path with the trailing field segment\n // dropped. `undefined` when the field is at form root.\n const parentPath = getParentPathInScope(entry.fieldKey);\n const groupValue = parentPath ? getNestedValue(formValue, parentPath) : undefined;\n\n return {\n fieldValue,\n formValue,\n fieldPath: entry.fieldKey,\n groupValue,\n customFunctions: context.customFunctions,\n externalData: context.externalData,\n logger: context.logger,\n };\n}\n\n/**\n * Creates an evaluation context scoped to a specific array item.\n *\n * @internal\n */\nfunction createArrayItemEvaluationContext(\n entry: PropertyDerivationEntry,\n arrayItem: Record<string, unknown>,\n rootFormValue: Record<string, unknown>,\n itemIndex: number,\n arrayPath: string,\n context: PropertyDerivationApplicatorContext,\n): EvaluationContext {\n const pathInfo = parseArrayPath(entry.fieldKey);\n const { relativePath, groupValue, fieldValue } = resolveArrayItemScope(pathInfo, arrayItem);\n\n return {\n fieldValue,\n formValue: arrayItem,\n fieldPath: relativePath ? `${arrayPath}.${itemIndex}.${relativePath}` : `${arrayPath}.${itemIndex}`,\n groupValue,\n customFunctions: context.customFunctions,\n externalData: context.externalData,\n logger: context.logger,\n rootFormValue,\n arrayIndex: itemIndex,\n arrayPath,\n };\n}\n\n/**\n * Evaluates the property derivation condition.\n *\n * @internal\n */\nfunction evaluatePropertyCondition(condition: ConditionalExpression | boolean, context: EvaluationContext): boolean {\n if (typeof condition === 'boolean') {\n return condition;\n }\n return evaluateCondition(condition, context);\n}\n\n/**\n * Computes the derived property value based on the entry configuration.\n *\n * @internal\n */\nfunction computePropertyValue(\n entry: PropertyDerivationEntry,\n evalContext: EvaluationContext,\n applicatorContext: PropertyDerivationApplicatorContext,\n): unknown {\n return computeValueFromEntry(entry, evalContext, {\n derivationFunctions: applicatorContext.derivationFunctions,\n subject: `${entry.fieldKey}.${entry.targetProperty}`,\n functionKind: 'Property derivation function',\n });\n}\n","import { HttpClient } from '@angular/common/http';\nimport { Signal, untracked } from '@angular/core';\nimport { EMPTY, Observable, debounceTime, filter, map, pairwise, startWith, switchMap } from 'rxjs';\nimport { catchError } from 'rxjs/operators';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport { getChangedKeys } from '@ng-forge/dynamic-forms/internal';\nimport { isArrayPlaceholderPath } from '../../utils/path-utils/path-utils';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport { evaluateCondition } from '@ng-forge/dynamic-forms/internal';\nimport { ExpressionParser } from '@ng-forge/dynamic-forms/internal';\nimport { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { resolveHttpRequest } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { PropertyDerivationEntry } from './property-derivation-types';\nimport { PropertyOverrideStore } from './property-override-store';\n\n/**\n * Context required for creating HTTP property-derivation streams.\n *\n * @internal\n */\nexport interface HttpPropertyDerivationStreamContext {\n /** Signal containing the current form value */\n formValue: Signal<Record<string, unknown>>;\n\n /** The property override store to write into */\n store: PropertyOverrideStore;\n\n /** Angular HttpClient for making requests */\n httpClient: HttpClient;\n\n /** Logger for diagnostic output */\n logger: Logger;\n\n /** Lazy resolver for custom functions — called per-emission to get fresh values */\n customFunctions?: () => Record<string, CustomFunction> | undefined;\n\n /** Lazy resolver for external data — called per-emission to get fresh values */\n externalData?: () => Record<string, unknown> | undefined;\n}\n\nconst LOG_PREFIX = 'HTTP Property Derivation -';\nconst DEFAULT_HTTP_DEBOUNCE_MS = 300;\n\n/**\n * Creates an RxJS Observable stream that processes an HTTP property-derivation entry.\n *\n * @internal\n */\nexport function createHttpPropertyDerivationStream(\n entry: PropertyDerivationEntry,\n formValue$: Observable<Record<string, unknown>>,\n context: HttpPropertyDerivationStreamContext,\n): Observable<void> {\n // Guard: no array support yet for HTTP property derivations (mirrors value pipeline).\n if (isArrayPlaceholderPath(entry.fieldKey)) {\n context.logger.warn(\n `${LOG_PREFIX} HTTP property derivation for array field '${entry.fieldKey}' is not supported. ` +\n `Array HTTP property derivations will be supported in a future release.`,\n );\n return EMPTY;\n }\n\n if (!entry.http || !entry.responseExpression) {\n return EMPTY;\n }\n\n const responseExpression = entry.responseExpression;\n const debounceMs = entry.debounceMs ?? DEFAULT_HTTP_DEBOUNCE_MS;\n\n return formValue$.pipe(\n startWith(null as Record<string, unknown> | null),\n pairwise(),\n map(([previous, current]) => ({\n current: current!,\n changedFields: getChangedKeys(previous, current!),\n })),\n filter(({ changedFields }) => {\n if (changedFields.size === 0) return false;\n return entry.dependsOn.some((dep) => changedFields.has(dep));\n }),\n debounceTime(debounceMs),\n switchMap(({ current }) => {\n return new Observable<void>((subscriber) => {\n const customFunctions = context.customFunctions?.();\n const externalData = context.externalData?.();\n\n const fieldValue = getNestedValue(current, entry.fieldKey);\n\n const evalContext: EvaluationContext = {\n fieldValue,\n formValue: current,\n fieldPath: entry.fieldKey,\n customFunctions,\n externalData,\n logger: context.logger,\n };\n\n // Evaluate condition — skip if false\n if (entry.condition !== true) {\n const conditionMet = typeof entry.condition === 'boolean' ? entry.condition : evaluateCondition(entry.condition, evalContext);\n if (!conditionMet) {\n subscriber.complete();\n return;\n }\n }\n\n // Returns null when a path param is undefined — suppress the request\n const resolvedRequest = resolveHttpRequest(entry.http!, evalContext);\n if (!resolvedRequest) {\n subscriber.complete();\n return;\n }\n\n const method = (resolvedRequest.method ?? 'GET').toUpperCase();\n // Inner cancellation is handled declaratively by the orchestrator's\n // outer switchMap: when the entry set changes, the outer stream\n // unsubscribes from this inner observable, which triggers the\n // cleanup callback below and cancels the in-flight HTTP request.\n const httpSub = context.httpClient\n .request(method, resolvedRequest.url, {\n body: resolvedRequest.body,\n headers: resolvedRequest.headers as Record<string, string>,\n })\n .subscribe({\n next: (response) => {\n try {\n const newValue = ExpressionParser.evaluate(responseExpression, { response });\n\n // `setOverride` performs its own deep-equality check, so we don't need to\n // compare with the current store value here.\n untracked(() => context.store.setOverride(entry.fieldKey, entry.targetProperty, newValue));\n\n subscriber.complete();\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Failed to process response for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);\n subscriber.complete();\n }\n },\n error: (error) => {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} HTTP request failed for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);\n subscriber.complete();\n },\n });\n\n return () => {\n httpSub.unsubscribe();\n };\n });\n }),\n catchError((error) => {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Unexpected stream error for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);\n return EMPTY;\n }),\n );\n}\n","import { Signal, untracked } from '@angular/core';\nimport { EMPTY, from, Observable, debounceTime, filter, map, pairwise, startWith, switchMap } from 'rxjs';\nimport { catchError } from 'rxjs/operators';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport { getChangedKeys } from '@ng-forge/dynamic-forms/internal';\nimport { isArrayPlaceholderPath } from '../../utils/path-utils/path-utils';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport type { AsyncDerivationFunction } from '@ng-forge/dynamic-forms/internal';\nimport { evaluateCondition } from '@ng-forge/dynamic-forms/internal';\nimport { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { PropertyDerivationEntry } from './property-derivation-types';\nimport { PropertyOverrideStore } from './property-override-store';\n\n/**\n * Context required for creating async-function property-derivation streams.\n *\n * @internal\n */\nexport interface AsyncPropertyDerivationStreamContext {\n /** Signal containing the current form value */\n formValue: Signal<Record<string, unknown>>;\n\n /** The property override store to write into */\n store: PropertyOverrideStore;\n\n /** Logger for diagnostic output */\n logger: Logger;\n\n /** Lazy resolver for custom functions — called per-emission to get fresh values */\n customFunctions?: () => Record<string, CustomFunction> | undefined;\n\n /** Lazy resolver for async derivation functions — called per-emission to get fresh values */\n asyncDerivationFunctions?: () => Record<string, AsyncDerivationFunction> | undefined;\n\n /** Lazy resolver for external data — called per-emission to get fresh values */\n externalData?: () => Record<string, unknown> | undefined;\n}\n\nconst LOG_PREFIX = 'Async Property Derivation -';\nconst DEFAULT_ASYNC_DEBOUNCE_MS = 300;\n\n/**\n * Creates an RxJS Observable stream that processes an async-function property-derivation entry.\n *\n * @internal\n */\nexport function createAsyncPropertyDerivationStream(\n entry: PropertyDerivationEntry,\n formValue$: Observable<Record<string, unknown>>,\n context: AsyncPropertyDerivationStreamContext,\n): Observable<void> {\n if (isArrayPlaceholderPath(entry.fieldKey)) {\n context.logger.warn(\n `${LOG_PREFIX} Async property derivation for array field '${entry.fieldKey}' is not supported. ` +\n `Array async property derivations will be supported in a future release.`,\n );\n return EMPTY;\n }\n\n if (!entry.asyncFunctionName && !entry.asyncFn) {\n context.logger.warn(\n `${LOG_PREFIX} Property derivation for '${entry.fieldKey}.${entry.targetProperty}' has neither \"asyncFunctionName\" nor \"asyncFn\". ` +\n `One of the two is required for source: 'asyncFunction'.`,\n );\n return EMPTY;\n }\n\n if (entry.asyncFn && entry.asyncFunctionName) {\n context.logger.warn(\n `${LOG_PREFIX} Both \"asyncFn\" and \"asyncFunctionName\" are set on property derivation for '${entry.fieldKey}.${entry.targetProperty}'. ` +\n `Inline \"asyncFn\" takes precedence; \"asyncFunctionName\" is ignored.`,\n );\n }\n\n const inlineAsyncFn = entry.asyncFn;\n const asyncFunctionName = entry.asyncFunctionName;\n const debounceMs = entry.debounceMs ?? DEFAULT_ASYNC_DEBOUNCE_MS;\n\n return formValue$.pipe(\n startWith(null as Record<string, unknown> | null),\n pairwise(),\n map(([previous, current]) => ({\n current: current!,\n changedFields: getChangedKeys(previous, current!),\n })),\n filter(({ changedFields }) => {\n if (changedFields.size === 0) return false;\n return entry.dependsOn.some((dep) => changedFields.has(dep));\n }),\n debounceTime(debounceMs),\n switchMap(({ current }) => {\n return new Observable<void>((subscriber) => {\n const customFunctions = context.customFunctions?.();\n const asyncDerivationFunctions = context.asyncDerivationFunctions?.();\n const externalData = context.externalData?.();\n\n const asyncFn = inlineAsyncFn ?? (asyncFunctionName ? asyncDerivationFunctions?.[asyncFunctionName] : undefined);\n if (!asyncFn) {\n context.logger.warn(\n `${LOG_PREFIX} Async derivation function '${asyncFunctionName}' not found for '${entry.fieldKey}.${entry.targetProperty}'. ` +\n `Register it in customFnConfig.asyncDerivations.`,\n );\n subscriber.complete();\n return;\n }\n\n const fieldValue = getNestedValue(current, entry.fieldKey);\n\n const evalContext: EvaluationContext = {\n fieldValue,\n formValue: current,\n fieldPath: entry.fieldKey,\n customFunctions,\n externalData,\n logger: context.logger,\n };\n\n if (entry.condition !== true) {\n const conditionMet = typeof entry.condition === 'boolean' ? entry.condition : evaluateCondition(entry.condition, evalContext);\n if (!conditionMet) {\n subscriber.complete();\n return;\n }\n }\n\n const result$ = from(asyncFn(evalContext));\n\n const asyncSub = result$.subscribe({\n next: (newValue) => {\n try {\n untracked(() => context.store.setOverride(entry.fieldKey, entry.targetProperty, newValue));\n subscriber.complete();\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Failed to process result for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);\n subscriber.complete();\n }\n },\n error: (error) => {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Async function failed for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);\n subscriber.complete();\n },\n complete: () => {\n subscriber.complete();\n },\n });\n\n return () => {\n asyncSub.unsubscribe();\n };\n });\n }),\n catchError((error) => {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Unexpected stream error for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);\n return EMPTY;\n }),\n );\n}\n","import { HttpClient } from '@angular/common/http';\nimport { computed, DestroyRef, inject, Injector, Signal, untracked } from '@angular/core';\nimport { DEV_MODE } from '../../utils/dev-mode';\nimport { toObservable } from '@angular/core/rxjs-interop';\nimport { FieldTree } from '@angular/forms/signals';\nimport { explicitEffect } from 'ngxtension/explicit-effect';\nimport { FieldDef } from '@ng-forge/dynamic-forms/internal';\nimport { FORM_OPTIONS } from '@ng-forge/dynamic-forms/internal';\nimport type { FormOptions } from '@ng-forge/dynamic-forms/internal';\nimport { DynamicFormLogger } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { DEFAULT_DEBOUNCE_MS } from '../../utils/debounce/debounce';\nimport { FunctionRegistryService } from '../registry';\nimport { applyDerivationsForTrigger } from './derivation-applicator';\nimport { collectAllDerivations, collectDerivations } from './derivation-collector';\nimport { collectPropertyDerivations } from '../property-derivation/property-derivation-collector';\nimport { validateNoCycles } from './cycle-detector';\nimport { DerivationCollection, DerivationEntry } from './derivation-types';\nimport { DerivationLogger } from './derivation-logger.service';\nimport { DERIVATION_WARNING_TRACKER } from './derivation-warning-tracker';\nimport { createHttpDerivationStream, HttpDerivationStreamContext } from './http-derivation-stream';\nimport { createAsyncDerivationStream } from './async-derivation-stream';\nimport { resolveExternalData } from './external-data-resolver';\nimport { setupDebouncedStream, setupEntryAsyncStream, setupOnChangeStream } from './pipeline-setup-utils';\nimport { applyPropertyDerivationsForTrigger } from '../property-derivation/property-derivation-applicator';\nimport { PropertyDerivationCollection, PropertyDerivationEntry } from '../property-derivation/property-derivation-types';\nimport { PropertyOverrideStore } from '../property-derivation/property-override-store';\nimport { createHttpPropertyDerivationStream } from '../property-derivation/http-property-derivation-stream';\nimport { createAsyncPropertyDerivationStream } from '../property-derivation/async-property-derivation-stream';\n\n/**\n * Configuration for creating a DerivationOrchestrator. Both pipelines are\n * opt-in via their respective config fields:\n */\nexport interface DerivationOrchestratorConfig {\n /** Signal containing the flattened schema fields */\n schemaFields: Signal<FieldDef<unknown>[] | undefined>;\n\n /** Signal containing the current form value */\n formValue: Signal<Record<string, unknown>>;\n\n /**\n * Signal containing the form accessor. **Required for the value pipeline.**\n * Omit (together with `derivationLogger`) to skip value-derivation setup.\n */\n form?: Signal<FieldTree<unknown>>;\n\n /**\n * Signal containing the derivation logger. **Required for the value\n * pipeline.** Omit (together with `form`) to skip value-derivation setup.\n */\n derivationLogger?: Signal<DerivationLogger>;\n\n /**\n * Property override store to write derived hidden/disabled/readonly states\n * into. **Required for the property pipeline.** Omit to skip\n * property-derivation setup.\n */\n propertyStore?: PropertyOverrideStore;\n\n /** Signal containing external data signals for expression evaluation */\n externalData?: Signal<Record<string, Signal<unknown>> | undefined>;\n}\n\n/**\n * Orchestrates derivation processing for a dynamic form — both **value\n * derivations** (writes into the form's FieldTree) and **property derivations**\n * (writes into a property override store). Each pipeline opts in via the\n * corresponding fields of {@link DerivationOrchestratorConfig}; an orchestrator\n * instance can run one, both, or (degenerately) neither.\n */\nexport class DerivationOrchestrator {\n private readonly config: DerivationOrchestratorConfig;\n private readonly injector = inject(Injector);\n private readonly destroyRef = inject(DestroyRef);\n private readonly logger = inject(DynamicFormLogger);\n private readonly warningTracker = inject(DERIVATION_WARNING_TRACKER);\n private readonly functionRegistry = inject(FunctionRegistryService);\n private readonly httpClient = inject(HttpClient, { optional: true });\n\n // Pipeline-specific deps are injected lazily inside the per-pipeline setup\n // methods so the unused token's provider isn't required when only one\n // pipeline is active (matters for tests + the future\n // withPropertyDerivations() / withDerivations() split). The value-pipeline\n // apply helpers read `this.formOptions` directly; the definite-assignment\n // assertion is safe because those helpers only fire from streams wired by\n // setupValuePipeline(), which assigns the signal first.\n private formOptions!: Signal<FormOptions | undefined>;\n\n /**\n * Computed signal containing the collected and validated value derivations.\n * Returns null when no value derivations are defined OR the value pipeline\n * was not activated (no `form`/`derivationLogger` in config).\n */\n readonly derivationCollection: Signal<DerivationCollection | null>;\n\n /**\n * Computed signal containing the collected property derivations. Returns\n * null when no property derivations are defined OR the property pipeline\n * was not activated (no `propertyStore` in config).\n */\n readonly propertyDerivationCollection: Signal<PropertyDerivationCollection | null>;\n\n constructor(config: DerivationOrchestratorConfig) {\n this.config = config;\n\n const valueEnabled = !!(config.form && config.derivationLogger);\n const propertyStore = config.propertyStore;\n const propertyEnabled = !!propertyStore;\n\n // Shared single-traversal collector — built only when BOTH pipelines are\n // active. When only one pipeline is active that pipeline's setup runs the\n // slice-specific collector directly. The memoization assumption: in the\n // common case where both pipeline signals are pulled in the same Angular\n // change-detection cycle, the shared computed runs once and both\n // consumers reuse the result. If only one consumer pulls within a frame,\n // the work is identical to the slice-collector path — no extra cost.\n const sharedAllDerivations =\n valueEnabled && propertyEnabled\n ? computed(() => {\n const fields = config.schemaFields();\n if (!fields || fields.length === 0) return null;\n return collectAllDerivations(fields);\n })\n : null;\n\n this.derivationCollection = valueEnabled\n ? this.setupValuePipeline(sharedAllDerivations)\n : computed<DerivationCollection | null>(() => null);\n\n this.propertyDerivationCollection = propertyStore\n ? this.setupPropertyPipeline(propertyStore, sharedAllDerivations)\n : computed<PropertyDerivationCollection | null>(() => null);\n }\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Value pipeline — setup\n // ─────────────────────────────────────────────────────────────────────────────\n\n private setupValuePipeline(\n sharedAllDerivations: Signal<{ value: DerivationCollection; property: PropertyDerivationCollection } | null> | null,\n ): Signal<DerivationCollection | null> {\n this.formOptions = inject(FORM_OPTIONS);\n\n const collection = computed<DerivationCollection | null>(() => {\n const fields = this.config.schemaFields();\n\n if (!fields || fields.length === 0) {\n return null;\n }\n\n // When both pipelines run, reuse the shared traversal; otherwise this\n // pipeline owns the walk (no property tracker needed).\n const collected = sharedAllDerivations ? (sharedAllDerivations()?.value ?? null) : collectDerivations(fields);\n\n if (!collected || collected.entries.length === 0) {\n return null;\n }\n\n validateNoCycles(collected, this.logger);\n if (DEV_MODE) {\n warnAboutWildcardDependencies(this.logger, collected.entries, fields.length);\n warnAboutMisconfiguredReEngagement(this.logger, collected.entries);\n }\n\n return collected;\n });\n\n setupOnChangeStream<DerivationCollection>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'Derivation onChange stream error',\n collectionSignal: collection,\n formValueSignal: this.config.formValue,\n // The form accessor signal must also wake the stream so that config swaps\n // (which produce a new FieldTree) trigger a re-application of derivations\n // against the freshly-built form.\n additionalAwakeners: [this.config.form!],\n applyOnChange: (current, changedFields) => {\n this.applyOnChangeDerivations(\n current,\n untracked(() => this.config.form!()),\n changedFields,\n );\n },\n });\n\n setupDebouncedStream<DerivationCollection>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'Derivation debounced stream error',\n collectionSignal: collection,\n formValueSignal: this.config.formValue,\n applyDebouncedForPeriod: (debounceMs, current, changedFields) => {\n const formAccessor = untracked(() => this.config.form!());\n if (!formAccessor) return;\n this.applyDebouncedEntriesForPeriod(debounceMs, current, formAccessor, changedFields);\n },\n });\n\n this.setupValueHttpStreams(collection);\n this.setupValueAsyncFunctionStreams(collection);\n\n return collection;\n }\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Property pipeline — setup\n // ─────────────────────────────────────────────────────────────────────────────\n\n private setupPropertyPipeline(\n propertyStore: PropertyOverrideStore,\n sharedAllDerivations: Signal<{ value: DerivationCollection; property: PropertyDerivationCollection } | null> | null,\n ): Signal<PropertyDerivationCollection | null> {\n const collection = computed<PropertyDerivationCollection | null>(() => {\n const fields = this.config.schemaFields();\n\n if (!fields || fields.length === 0) {\n return null;\n }\n\n // When both pipelines run, reuse the shared traversal; otherwise this\n // pipeline owns the walk.\n const collected = sharedAllDerivations ? (sharedAllDerivations()?.property ?? null) : collectPropertyDerivations(fields);\n\n if (!collected || collected.entries.length === 0) {\n return null;\n }\n\n return collected;\n });\n\n // Side effect: clear store + register fields whenever the collection changes.\n // schemaFields is in the dep array so the field count is always in sync.\n explicitEffect([collection, this.config.schemaFields], ([current, fields]) => {\n propertyStore.clear();\n\n if (!current) return;\n\n for (const entry of current.entries) {\n propertyStore.registerField(entry.fieldKey);\n }\n\n if (DEV_MODE) {\n warnAboutWildcardPropertyDependencies(this.logger, current.entries, fields?.length ?? 0);\n }\n });\n\n setupOnChangeStream<PropertyDerivationCollection>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'Property derivation onChange stream error',\n collectionSignal: collection,\n formValueSignal: this.config.formValue,\n // Property pipeline's onChange historically ignored the changed-fields set\n // (it always re-runs all 'onChange' entries). The helper computes one anyway;\n // we drop it here to preserve the original behavior.\n applyOnChange: (current) => this.applyOnChangePropertyDerivations(current, propertyStore),\n });\n\n setupDebouncedStream<PropertyDerivationCollection>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'Property derivation debounced stream error',\n collectionSignal: collection,\n formValueSignal: this.config.formValue,\n applyDebouncedForPeriod: (debounceMs, current, changedFields) => {\n this.applyDebouncedPropertyEntriesForPeriod(debounceMs, current, propertyStore, changedFields);\n },\n });\n\n this.setupPropertyHttpStreams(collection, propertyStore);\n this.setupPropertyAsyncFunctionStreams(collection, propertyStore);\n\n return collection;\n }\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Value pipeline — apply helpers\n // ─────────────────────────────────────────────────────────────────────────────\n\n private applyOnChangeDerivations(collection: DerivationCollection, formAccessor: FieldTree<unknown>, changedFields?: Set<string>): void {\n const applicatorContext = {\n formValue: this.config.formValue,\n rootForm: formAccessor,\n derivationFunctions: this.functionRegistry.getDerivationFunctions(),\n customFunctions: this.functionRegistry.getCustomFunctions(),\n externalData: resolveExternalData(this.config.externalData),\n logger: this.logger,\n warningTracker: this.warningTracker,\n derivationLogger: untracked(() => this.config.derivationLogger!()),\n maxIterations: untracked(() => this.formOptions()?.maxDerivationIterations),\n };\n\n const result = applyDerivationsForTrigger(collection, 'onChange', applicatorContext, changedFields);\n\n if (result.maxIterationsReached) {\n this.logger.warn(\n `Derivation processing reached max iterations. ` +\n `This may indicate a loop in derivation logic that wasn't caught at build time. ` +\n `Applied: ${result.appliedCount}, Skipped: ${result.skippedCount}, Errors: ${result.errorCount}`,\n );\n }\n }\n\n private applyDebouncedEntriesForPeriod(\n debounceMs: number,\n collection: DerivationCollection,\n formAccessor: FieldTree<unknown>,\n changedFields?: Set<string>,\n ): void {\n const debouncedEntries = collection.entries.filter(\n (entry) => entry.trigger === 'debounced' && (entry.debounceMs ?? DEFAULT_DEBOUNCE_MS) === debounceMs,\n );\n\n if (debouncedEntries.length === 0) {\n return;\n }\n\n const filteredCollection: DerivationCollection = {\n entries: debouncedEntries,\n };\n\n const applicatorContext = {\n formValue: this.config.formValue,\n rootForm: formAccessor,\n derivationFunctions: this.functionRegistry.getDerivationFunctions(),\n customFunctions: this.functionRegistry.getCustomFunctions(),\n externalData: resolveExternalData(this.config.externalData),\n logger: this.logger,\n warningTracker: this.warningTracker,\n derivationLogger: untracked(() => this.config.derivationLogger!()),\n maxIterations: untracked(() => this.formOptions()?.maxDerivationIterations),\n };\n\n const result = applyDerivationsForTrigger(filteredCollection, 'debounced', applicatorContext, changedFields);\n\n if (result.maxIterationsReached) {\n this.logger.warn(\n `Debounced derivation processing reached max iterations (${debounceMs}ms). ` +\n `Applied: ${result.appliedCount}, Skipped: ${result.skippedCount}, Errors: ${result.errorCount}`,\n );\n }\n }\n\n private setupValueHttpStreams(collectionSignal: Signal<DerivationCollection | null>): void {\n const formValue$ = toObservable(this.config.formValue, { injector: this.injector });\n\n setupEntryAsyncStream<DerivationCollection, DerivationEntry>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'HTTP Derivation: outer stream error',\n collectionSignal,\n selectEntries: (collection) => collection?.entries.filter((entry) => entry.http) ?? [],\n entrySignature: valueHttpEntrySignature,\n createStream: (entry) => {\n if (!this.httpClient) {\n this.logger.error(\n 'HTTP Derivation: HttpClient is not available. Ensure provideHttpClient() is included in your application providers.',\n );\n return null;\n }\n const context: HttpDerivationStreamContext = {\n formValue: this.config.formValue,\n form: this.config.form!,\n httpClient: this.httpClient,\n logger: this.logger,\n derivationLogger: this.config.derivationLogger!,\n customFunctions: () => this.functionRegistry.getCustomFunctions(),\n externalData: () => resolveExternalData(this.config.externalData),\n warningTracker: this.warningTracker,\n };\n return createHttpDerivationStream(entry, formValue$, context);\n },\n });\n }\n\n private setupValueAsyncFunctionStreams(collectionSignal: Signal<DerivationCollection | null>): void {\n const formValue$ = toObservable(this.config.formValue, { injector: this.injector });\n\n setupEntryAsyncStream<DerivationCollection, DerivationEntry>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'Async Derivation: outer stream error',\n collectionSignal,\n selectEntries: (collection) => collection?.entries.filter((entry) => entry.asyncFunctionName || entry.asyncFn) ?? [],\n entrySignature: valueAsyncEntrySignature,\n createStream: (entry) =>\n createAsyncDerivationStream(entry, formValue$, {\n formValue: this.config.formValue,\n form: this.config.form!,\n logger: this.logger,\n derivationLogger: this.config.derivationLogger!,\n customFunctions: () => this.functionRegistry.getCustomFunctions(),\n asyncDerivationFunctions: () => this.functionRegistry.getAsyncDerivationFunctions(),\n externalData: () => resolveExternalData(this.config.externalData),\n warningTracker: this.warningTracker,\n }),\n });\n }\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Property pipeline — apply helpers\n // ─────────────────────────────────────────────────────────────────────────────\n\n private applyOnChangePropertyDerivations(collection: PropertyDerivationCollection, store: PropertyOverrideStore): void {\n const applicatorContext = {\n formValue: this.config.formValue,\n store,\n derivationFunctions: this.functionRegistry.getDerivationFunctions(),\n customFunctions: this.functionRegistry.getCustomFunctions(),\n externalData: resolveExternalData(this.config.externalData),\n logger: this.logger,\n warningTracker: this.warningTracker,\n };\n\n applyPropertyDerivationsForTrigger(collection, 'onChange', applicatorContext);\n }\n\n private applyDebouncedPropertyEntriesForPeriod(\n debounceMs: number,\n collection: PropertyDerivationCollection,\n store: PropertyOverrideStore,\n changedFields?: Set<string>,\n ): void {\n const debouncedEntries = collection.entries.filter(\n (entry) => entry.trigger === 'debounced' && (entry.debounceMs ?? DEFAULT_DEBOUNCE_MS) === debounceMs,\n );\n\n if (debouncedEntries.length === 0) return;\n\n const filteredCollection: PropertyDerivationCollection = { entries: debouncedEntries };\n\n const applicatorContext = {\n formValue: this.config.formValue,\n store,\n derivationFunctions: this.functionRegistry.getDerivationFunctions(),\n customFunctions: this.functionRegistry.getCustomFunctions(),\n externalData: resolveExternalData(this.config.externalData),\n logger: this.logger,\n warningTracker: this.warningTracker,\n };\n\n applyPropertyDerivationsForTrigger(filteredCollection, 'debounced', applicatorContext, changedFields);\n }\n\n private setupPropertyHttpStreams(collectionSignal: Signal<PropertyDerivationCollection | null>, store: PropertyOverrideStore): void {\n const formValue$ = toObservable(this.config.formValue, { injector: this.injector });\n\n setupEntryAsyncStream<PropertyDerivationCollection, PropertyDerivationEntry>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'HTTP Property Derivation: outer stream error',\n collectionSignal,\n selectEntries: (collection) => collection?.entries.filter((entry) => entry.http) ?? [],\n entrySignature: propertyHttpEntrySignature,\n createStream: (entry) => {\n if (!this.httpClient) {\n this.logger.error(\n 'HTTP Property Derivation: HttpClient is not available. Ensure provideHttpClient() is included in your application providers.',\n );\n return null;\n }\n return createHttpPropertyDerivationStream(entry, formValue$, {\n formValue: this.config.formValue,\n store,\n httpClient: this.httpClient,\n logger: this.logger,\n customFunctions: () => this.functionRegistry.getCustomFunctions(),\n externalData: () => resolveExternalData(this.config.externalData),\n });\n },\n });\n }\n\n private setupPropertyAsyncFunctionStreams(\n collectionSignal: Signal<PropertyDerivationCollection | null>,\n store: PropertyOverrideStore,\n ): void {\n const formValue$ = toObservable(this.config.formValue, { injector: this.injector });\n\n setupEntryAsyncStream<PropertyDerivationCollection, PropertyDerivationEntry>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'Async Property Derivation: outer stream error',\n collectionSignal,\n selectEntries: (collection) => collection?.entries.filter((entry) => entry.asyncFunctionName || entry.asyncFn) ?? [],\n entrySignature: propertyAsyncEntrySignature,\n createStream: (entry) =>\n createAsyncPropertyDerivationStream(entry, formValue$, {\n formValue: this.config.formValue,\n store,\n logger: this.logger,\n customFunctions: () => this.functionRegistry.getCustomFunctions(),\n asyncDerivationFunctions: () => this.functionRegistry.getAsyncDerivationFunctions(),\n externalData: () => resolveExternalData(this.config.externalData),\n }),\n });\n }\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Entry signatures — value pipeline\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Identity signature for an HTTP value-derivation entry. Includes every field\n * that drives the inner stream's behavior — changing any of them must tear\n * down and rebuild the stream. Stable across topological reorderings because\n * the consumer ({@link entrySetsEqual}) compares as a multiset.\n */\nfunction valueHttpEntrySignature(entry: DerivationEntry): string {\n const config = {\n http: entry.http,\n responseExpression: entry.responseExpression,\n dependsOn: entry.dependsOn,\n debounceMs: entry.debounceMs,\n trigger: entry.trigger,\n condition: entry.condition,\n stopOnUserOverride: entry.stopOnUserOverride,\n reEngageOnDependencyChange: entry.reEngageOnDependencyChange,\n };\n return `${entry.fieldKey}:${JSON.stringify(config)}`;\n}\n\nfunction valueAsyncEntrySignature(entry: DerivationEntry): string {\n // For inline `asyncFn` entries we tag the signature with the field path (the\n // real identity, since derivations are self-targeting) rather than the array\n // index. Index-based tagging churned identities whenever topologicalSort\n // reordered entries, causing unnecessary teardown + re-subscribe.\n const config = {\n asyncFunctionName: entry.asyncFunctionName,\n asyncFnId: entry.asyncFn ? `inline:${entry.fieldKey}` : undefined,\n dependsOn: entry.dependsOn,\n debounceMs: entry.debounceMs,\n trigger: entry.trigger,\n condition: entry.condition,\n stopOnUserOverride: entry.stopOnUserOverride,\n reEngageOnDependencyChange: entry.reEngageOnDependencyChange,\n };\n return `${entry.fieldKey}:${JSON.stringify(config)}`;\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Entry signatures — property pipeline\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction propertyHttpEntrySignature(entry: PropertyDerivationEntry): string {\n const config = {\n http: entry.http,\n responseExpression: entry.responseExpression,\n dependsOn: entry.dependsOn,\n debounceMs: entry.debounceMs,\n trigger: entry.trigger,\n condition: entry.condition,\n };\n return `${entry.fieldKey}.${entry.targetProperty}:${JSON.stringify(config)}`;\n}\n\nfunction propertyAsyncEntrySignature(entry: PropertyDerivationEntry): string {\n const config = {\n asyncFunctionName: entry.asyncFunctionName,\n asyncFnId: entry.asyncFn ? `inline:${entry.fieldKey}.${entry.targetProperty}` : undefined,\n dependsOn: entry.dependsOn,\n debounceMs: entry.debounceMs,\n trigger: entry.trigger,\n condition: entry.condition,\n };\n return `${entry.fieldKey}.${entry.targetProperty}:${JSON.stringify(config)}`;\n}\n\n/**\n * Creates a DerivationOrchestrator for a dynamic form.\n * Must be called within an injection context.\n *\n * @param config - Form-specific signals configuration\n * @returns The created DerivationOrchestrator\n */\nexport function createDerivationOrchestrator(config: DerivationOrchestratorConfig): DerivationOrchestrator {\n return new DerivationOrchestrator(config);\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Dev-mode diagnostics (tree-shaken in prod via DEV_MODE gate at call site)\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction warnAboutWildcardDependencies(logger: Logger, entries: DerivationEntry[], fieldCount: number): void {\n const wildcardEntries = entries.filter((entry) => entry.dependsOn.includes('*'));\n if (wildcardEntries.length === 0) return;\n\n // HTTP and async entries require explicit dependsOn (validated at collection time)\n const implicitWildcards = wildcardEntries.filter(\n (entry) =>\n !entry.http &&\n !entry.asyncFunctionName &&\n !entry.asyncFn &&\n (entry.functionName || entry.fn) &&\n (!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0),\n );\n\n if (implicitWildcards.length > 0) {\n const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey} (${e.functionName ?? '<inline fn>'})`);\n logger.warn(\n 'Derivation: custom functions without explicit dependsOn detected. ' +\n `These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +\n 'Consider specifying explicit dependsOn arrays for better performance.',\n derivationDescs,\n );\n }\n}\n\nfunction warnAboutMisconfiguredReEngagement(logger: Logger, entries: DerivationEntry[]): void {\n const misconfigured = entries.filter((entry) => entry.reEngageOnDependencyChange && !entry.stopOnUserOverride);\n if (misconfigured.length === 0) return;\n\n const fieldKeys = misconfigured.map((e) => e.debugName ?? e.fieldKey);\n logger.warn(\n 'Derivation: reEngageOnDependencyChange without stopOnUserOverride detected. ' +\n 'reEngageOnDependencyChange only takes effect when stopOnUserOverride is true. ' +\n 'Either add stopOnUserOverride: true or remove reEngageOnDependencyChange.',\n fieldKeys,\n );\n}\n\nfunction warnAboutWildcardPropertyDependencies(logger: Logger, entries: PropertyDerivationEntry[], fieldCount: number): void {\n const implicitWildcards = entries.filter(\n (entry) =>\n entry.dependsOn.includes('*') &&\n (entry.functionName || entry.fn) &&\n (!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0),\n );\n\n if (implicitWildcards.length > 0) {\n const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey}.${e.targetProperty} (${e.functionName ?? '<inline fn>'})`);\n logger.warn(\n 'PropertyDerivation: custom functions without explicit dependsOn detected. ' +\n `These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +\n 'Consider specifying explicit dependsOn arrays for better performance.',\n derivationDescs,\n );\n }\n}\n","import { computed, inject, Signal } from '@angular/core';\nimport { FieldTree } from '@angular/forms/signals';\nimport { DerivationLogConfig, DynamicFormLogger, EXTERNAL_DATA, FieldDef } from '@ng-forge/dynamic-forms/internal';\nimport { DERIVATION_LOG_CONFIG } from '../../providers/features/logger/with-logger-config';\nimport { FormStateManager } from '../../state/form-state-manager';\nimport { PROPERTY_OVERRIDE_STORE } from '../property-derivation/property-override-store';\nimport { createDerivationLogger } from './derivation-logger.service';\nimport { createDerivationOrchestrator, DerivationOrchestratorConfig } from './derivation-orchestrator';\n\n/**\n * Lazy entry point for the derivation engine. This module is the dynamic-import\n * chunk boundary: it statically imports the heavy {@link createDerivationOrchestrator}\n * (and the value/property/HTTP/async stream factories it pulls), so the whole\n * engine only loads when a config actually declares a derivation.\n *\n * Builds the orchestrator config from the form-scoped DI services and constructs\n * the orchestrator. Must be called within the form's injection context (the\n * orchestrator reads `inject(DestroyRef)`/`inject(FORM_OPTIONS)` etc. in its field\n * initializers). The instance is retained by the reactive stream subscriptions it\n * wires (tied to the component `DestroyRef`), so the return value is intentionally\n * discarded.\n *\n * @internal\n */\nexport function bootstrapDerivationOrchestrator(): void {\n const stateManager = inject(FormStateManager);\n const logger = inject(DynamicFormLogger);\n const logConfig = inject<DerivationLogConfig>(DERIVATION_LOG_CONFIG);\n const externalData = inject(EXTERNAL_DATA);\n const propertyStore = inject(PROPERTY_OVERRIDE_STORE);\n\n // FormStateManager is injected without type parameters, so its generic defaults\n // to Record<string, unknown>. The casts widen the type to match\n // DerivationOrchestratorConfig (which uses unknown) — safe because the\n // orchestrator only reads values.\n const config: DerivationOrchestratorConfig = {\n schemaFields: computed(() => stateManager.formSetup()?.schemaFields as FieldDef<unknown>[] | undefined),\n formValue: stateManager.formValue as Signal<Record<string, unknown>>,\n form: computed(() => stateManager.form() as unknown as FieldTree<unknown>),\n derivationLogger: computed(() => createDerivationLogger(logConfig, logger)),\n propertyStore,\n externalData,\n };\n\n createDerivationOrchestrator(config);\n}\n"]}
1
+ {"version":3,"file":"ng-forge-dynamic-forms-bootstrap-derivation-orchestrator-D6ntLtW9.mjs","mappings":"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","names":[],"ignoreList":[],"sources":["../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-logger.service.ts","../../../../packages/dynamic-forms/src/lib/utils/path-utils/path-utils.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-types.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/compute-derived-value.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/evaluation-scope.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-constants.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/field-value-utils.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-applicator.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-sorter.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/extract-dependencies.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/extract-inline-fn.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/collection-context.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-collector.ts","../../../../packages/dynamic-forms/src/lib/core/property-derivation/property-derivation-collector.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/cycle-detector.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/http-derivation-stream.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/async-derivation-stream.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/external-data-resolver.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/debounce-period-utils.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/entry-set-utils.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/pipeline-setup-utils.ts","../../../../packages/dynamic-forms/src/lib/core/property-derivation/property-derivation-types.ts","../../../../packages/dynamic-forms/src/lib/core/property-derivation/property-derivation-applicator.ts","../../../../packages/dynamic-forms/src/lib/core/property-derivation/http-property-derivation-stream.ts","../../../../packages/dynamic-forms/src/lib/core/property-derivation/async-property-derivation-stream.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/derivation-orchestrator.ts","../../../../packages/dynamic-forms/src/lib/core/derivation/bootstrap-derivation-orchestrator.ts"],"sourcesContent":["import { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { DerivationLogConfig, createDefaultDerivationLogConfig, shouldLog } from '@ng-forge/dynamic-forms/internal';\nimport { DerivationProcessingResult } from './derivation-types';\nimport { DerivationLogEntry } from './derivation-logger';\n\n/** Service interface for derivation logging. */\nexport interface DerivationLogger {\n /**\n * Logs the start of a derivation processing cycle.\n * Only logs when config level is 'verbose'.\n */\n cycleStart(trigger: 'onChange' | 'debounced', entryCount: number): void;\n\n /**\n * Logs the start of a derivation iteration within a cycle.\n * Only logs when config level is 'verbose'.\n */\n iteration(iterationNumber: number): void;\n\n /**\n * Logs a single derivation evaluation result.\n * Only logs when config level is 'verbose'.\n */\n evaluation(entry: DerivationLogEntry): void;\n\n /**\n * Logs a summary of derivation processing results.\n * Logs when config level is 'summary' or 'verbose' and something interesting happened.\n */\n summary(result: DerivationProcessingResult, trigger: 'onChange' | 'debounced'): void;\n\n /** Logs when max iterations are reached (always logged as warning). */\n maxIterationsReached(result: DerivationProcessingResult, trigger: 'onChange' | 'debounced'): void;\n}\n\n/**\n * Active implementation of DerivationLogger that performs actual logging.\n *\n * @internal\n */\nclass ActiveDerivationLogger implements DerivationLogger {\n logger!: Logger;\n private config: DerivationLogConfig = createDefaultDerivationLogConfig();\n\n /** Updates the log configuration. */\n setConfig(config: DerivationLogConfig): void {\n this.config = config;\n }\n\n cycleStart(trigger: 'onChange' | 'debounced', entryCount: number): void {\n if (!shouldLog(this.config, 'verbose')) return;\n this.logger.debug(`Derivation - Starting cycle (${trigger}) with ${entryCount} derivation(s)`);\n }\n\n iteration(iterationNumber: number): void {\n if (!shouldLog(this.config, 'verbose')) return;\n this.logger.debug(`Derivation - Iteration ${iterationNumber}`);\n }\n\n evaluation(entry: DerivationLogEntry): void {\n if (!shouldLog(this.config, 'verbose')) return;\n\n const name = entry.debugName ? `\"${entry.debugName}\"` : entry.fieldKey;\n\n switch (entry.result) {\n case 'applied':\n this.logger.debug(`Derivation - Applied ${name}`, {\n field: entry.fieldKey,\n previousValue: entry.previousValue,\n newValue: entry.newValue,\n ...(entry.durationMs !== undefined && { durationMs: entry.durationMs }),\n });\n break;\n\n case 'skipped':\n this.logger.debug(`Derivation - Skipped ${name} (${this.formatSkipReason(entry.skipReason)})`, {\n field: entry.fieldKey,\n reason: entry.skipReason,\n });\n break;\n\n case 'error':\n this.logger.debug(`Derivation - Error ${name}`, {\n field: entry.fieldKey,\n error: entry.error,\n });\n break;\n }\n }\n\n summary(result: DerivationProcessingResult, trigger: 'onChange' | 'debounced'): void {\n if (!shouldLog(this.config, 'summary')) return;\n\n // Only log if something interesting happened\n if (result.appliedCount === 0 && result.errorCount === 0) return;\n\n this.logger.debug(`Derivation - Cycle complete (${trigger})`, {\n applied: result.appliedCount,\n skipped: result.skippedCount,\n errors: result.errorCount,\n iterations: result.iterations,\n });\n }\n\n maxIterationsReached(result: DerivationProcessingResult, trigger: 'onChange' | 'debounced'): void {\n this.logger.warn(\n `Derivation - Max iterations reached (${trigger}). ` +\n `This may indicate a loop in derivation logic. ` +\n `Applied: ${result.appliedCount}, Skipped: ${result.skippedCount}, Errors: ${result.errorCount}`,\n );\n }\n\n private formatSkipReason(reason?: string): string {\n switch (reason) {\n case 'condition-false':\n return 'condition not met';\n case 'value-unchanged':\n return 'value unchanged';\n case 'already-applied':\n return 'already applied this cycle';\n case 'user-override':\n return 'user override active';\n case 'target-not-found':\n return 'target not found';\n default:\n return 'unknown';\n }\n }\n}\n\n/**\n * No-op implementation of DerivationLogger.\n * All methods are empty - no logging overhead when disabled.\n *\n * @internal\n */\nclass NoopDerivationLogger implements DerivationLogger {\n cycleStart(): void {\n /* no-op */\n }\n iteration(): void {\n /* no-op */\n }\n evaluation(): void {\n /* no-op */\n }\n summary(): void {\n /* no-op */\n }\n maxIterationsReached(): void {\n /* no-op */\n }\n}\n\n/** Singleton no-op instance to avoid creating new objects */\nconst NOOP_INSTANCE = new NoopDerivationLogger();\n\n/**\n * Factory function to create a DerivationLogger.\n *\n * @param config - The derivation log configuration\n * @param logger - The underlying logger instance\n * @returns A DerivationLogger instance\n */\nexport function createDerivationLogger(config: DerivationLogConfig | undefined, logger: Logger): DerivationLogger {\n const effectiveConfig = config ?? createDefaultDerivationLogConfig();\n\n // Return no-op if logging is completely disabled\n if (effectiveConfig.level === 'none') {\n return NOOP_INSTANCE;\n }\n\n // Create active logger with config\n const service = new ActiveDerivationLogger();\n service.logger = logger;\n service.setConfig(effectiveConfig);\n\n return service;\n}\n","/**\n * Utilities for parsing and manipulating field paths in dynamic forms.\n *\n * @module path-utils\n */\n\n/** Result of parsing an array derivation path. */\nexport interface ArrayPathInfo {\n /** The array field path (e.g., \"items\" or \"orders.lineItems\") */\n arrayPath: string;\n\n /** The relative field path within the array item (e.g., \"quantity\" or \"lineTotal\") */\n relativePath: string;\n\n /** Whether this is a valid array path (contains .$. separator) */\n isArrayPath: boolean;\n}\n\n/**\n * Parses an array derivation path into its components.\n *\n * @param path - The path to parse (e.g., \"items.$.quantity\")\n * @returns Parsed path info with arrayPath and relativePath\n */\nexport function parseArrayPath(path: string): ArrayPathInfo {\n const ARRAY_PLACEHOLDER = '.$.';\n const placeholderIndex = path.indexOf(ARRAY_PLACEHOLDER);\n\n if (placeholderIndex === -1) {\n return {\n arrayPath: '',\n relativePath: '',\n isArrayPath: false,\n };\n }\n\n return {\n arrayPath: path.substring(0, placeholderIndex),\n relativePath: path.substring(placeholderIndex + ARRAY_PLACEHOLDER.length),\n isArrayPath: true,\n };\n}\n\n/**\n * Resolves an array placeholder path to a concrete indexed path.\n *\n * @param path - The path with $ placeholder (e.g., \"items.$.quantity\")\n * @param index - The array index to substitute\n * @returns Resolved path with index (e.g., \"items.0.quantity\")\n */\nexport function resolveArrayPath(path: string, index: number): string {\n const info = parseArrayPath(path);\n if (!info.isArrayPath) {\n return path;\n }\n return `${info.arrayPath}.${index}.${info.relativePath}`;\n}\n\n/**\n * Checks if a path contains an array placeholder.\n *\n * @param path - The path to check\n * @returns True if the path contains .$.\n */\nexport function isArrayPlaceholderPath(path: string): boolean {\n return path.includes('.$.');\n}\n\n/**\n * Extracts the array path from a path that may contain an array placeholder.\n *\n * @param path - The path to extract from\n * @returns The array path, or empty string if not an array path\n */\nexport function extractArrayPath(path: string): string {\n return parseArrayPath(path).arrayPath;\n}\n\n/**\n * Splits a path into segments, supporting both dot notation and bracket notation.\n *\n * @param path - The path to split\n * @returns Array of path segments\n */\nexport function splitPath(path: string): string[] {\n if (!path) return [];\n // Convert bracket notation [n] to .n, then split by dots\n // Filter out empty segments from leading/trailing/consecutive dots\n return path\n .replace(/\\[(\\d+)\\]/g, '.$1')\n .split('.')\n .filter(Boolean);\n}\n\n/**\n * Joins path segments into a dot-separated path.\n *\n * @param segments - The path segments to join\n * @returns Joined path string\n */\nexport function joinPath(segments: string[]): string {\n return segments.join('.');\n}\n\n/**\n * Gets the parent path from a nested path.\n *\n * @param path - The path to get parent from\n * @returns The parent path, or empty string if no parent\n */\nexport function getParentPath(path: string): string {\n const segments = splitPath(path);\n if (segments.length <= 1) return '';\n return joinPath(segments.slice(0, -1));\n}\n\n/**\n * Gets the last segment (leaf) from a path.\n *\n * @param path - The path to get leaf from\n * @returns The last segment\n */\nexport function getLeafPath(path: string): string {\n const segments = splitPath(path);\n return segments[segments.length - 1] ?? '';\n}\n\n// ============================================================================\n// Multi-Array Path Utilities\n// ============================================================================\n\n/** Result of parsing a path with multiple array placeholders. */\nexport interface MultiArrayPathInfo {\n /** Whether this path contains array placeholders */\n isArrayPath: boolean;\n\n /** Number of array placeholders in the path */\n placeholderCount: number;\n\n /** Path segments (excluding $ placeholders) */\n segments: string[];\n\n /** The positions of '$' in the split path (by part index) */\n placeholderPositions: number[];\n}\n\n/**\n * Parses a path that may contain multiple array placeholders.\n *\n * @param path - Path with zero or more $ placeholders\n * @returns Parsed path information\n */\nexport function parseMultiArrayPath(path: string): MultiArrayPathInfo {\n const parts = path.split('.');\n const segments: string[] = [];\n const placeholderPositions: number[] = [];\n\n for (let i = 0; i < parts.length; i++) {\n if (parts[i] === '$') {\n placeholderPositions.push(i);\n } else {\n segments.push(parts[i]);\n }\n }\n\n return {\n isArrayPath: placeholderPositions.length > 0,\n placeholderCount: placeholderPositions.length,\n segments,\n placeholderPositions,\n };\n}\n\n/**\n * Resolves a path with multiple array placeholders using provided indices.\n *\n * @param path - Path with $ placeholders (e.g., 'orders.$.items.$.quantity')\n * @param indices - Array of indices to substitute (e.g., [0, 2])\n * @returns Resolved path (e.g., 'orders.0.items.2.quantity')\n * @throws Error if number of indices doesn't match number of placeholders\n */\nexport function resolveMultiArrayPath(path: string, indices: number[]): string {\n const parts = path.split('.');\n let indexPointer = 0;\n\n const resolved = parts.map((part) => {\n if (part === '$') {\n if (indexPointer >= indices.length) {\n throw new Error(\n `Not enough indices provided for path '${path}'. ` +\n `Expected ${parts.filter((p) => p === '$').length} indices, got ${indices.length}.`,\n );\n }\n return String(indices[indexPointer++]);\n }\n return part;\n });\n\n if (indexPointer < indices.length) {\n throw new Error(`Too many indices provided for path '${path}'. ` + `Expected ${indexPointer} indices, got ${indices.length}.`);\n }\n\n return resolved.join('.');\n}\n\n/**\n * Counts the number of array placeholders in a path.\n *\n * @param path - The path to analyze\n * @returns Number of $ placeholders in the path\n */\nexport function countArrayPlaceholders(path: string): number {\n return path.split('.').filter((part) => part === '$').length;\n}\n","import type { FormFieldStateMap } from '@ng-forge/dynamic-forms/internal';\nimport { HttpRequestConfig } from '@ng-forge/dynamic-forms/internal';\nimport type { AsyncDerivationFunction } from '@ng-forge/dynamic-forms/internal';\nimport { BaseDerivationEntry } from './derivation-entry-base';\n\n/** Entry representing a collected derivation from field definitions. */\nexport interface DerivationEntry extends BaseDerivationEntry {\n /**\n * Target property name when this entry feeds the property-override store\n * (one of `'hidden'`, `'disabled'`, `'readonly'`, ...). When absent, the\n * entry is a value derivation and writes into the FieldTree.\n */\n targetProperty?: string;\n\n /** HTTP request configuration for server-driven derivations. */\n http?: HttpRequestConfig;\n\n /** Name of a registered async derivation function. */\n asyncFunctionName?: string;\n\n /**\n * Inline async derivation function (code-only authoring).\n * Mutually exclusive with `asyncFunctionName`. NOT JSON-serializable.\n * Processed asynchronously in a dedicated RxJS stream, not in the sync loop.\n */\n asyncFn?: AsyncDerivationFunction;\n\n /** Expression to extract the derived value from an HTTP response. */\n responseExpression?: string;\n\n /**\n * Whether this derivation was created from the shorthand `derivation` property.\n * Value-pipeline-only — property derivations are always created from full\n * logic configs and leave this unset.\n */\n isShorthand?: boolean;\n\n /**\n * When true, the derivation stops running after the user manually\n * edits the target field. Value-pipeline-only — property derivations\n * always recompute on dependency change.\n */\n stopOnUserOverride?: boolean;\n\n /**\n * When true (and `stopOnUserOverride` is also true), clears the\n * user-override flag when any dependency of this derivation changes,\n * allowing the derivation to run again. Value-pipeline-only.\n */\n reEngageOnDependencyChange?: boolean;\n}\n\n/** Collection of all derivation entries from a form's field definitions. */\nexport interface DerivationCollection {\n /**\n * All derivation entries collected from field definitions,\n * sorted in topological order for efficient processing.\n */\n entries: DerivationEntry[];\n}\n\n/** Result of cycle detection in the derivation graph. */\nexport interface CycleDetectionResult {\n /** Whether a cycle was detected. */\n hasCycle: boolean;\n\n /** Field paths involved in the cycle, if one was detected. */\n cyclePath?: string[];\n\n /** Bidirectional derivation pairs detected (A↔B patterns). */\n bidirectionalPairs?: string[];\n\n /** Human-readable error message describing the cycle. */\n errorMessage?: string;\n}\n\n/**\n * Context for tracking derivation chain during runtime.\n *\n * @internal\n */\nexport interface DerivationChainContext {\n /** Set of derivation keys that have already been applied. */\n appliedDerivations: Set<string>;\n\n /** Current iteration count in the derivation processing loop. */\n iteration: number;\n\n /** Set of field keys that changed in this cycle. */\n changedFields?: Set<string>;\n\n /** Cached FormFieldStateMap for this cycle. */\n formFieldState?: FormFieldStateMap;\n}\n\n/**\n * Creates an empty derivation collection.\n *\n * @returns Empty collection ready for population\n * @internal\n */\nexport function createEmptyDerivationCollection(): DerivationCollection {\n return { entries: [] };\n}\n\n/**\n * Creates a new derivation chain context for tracking applied derivations.\n *\n * @returns Fresh context for a new derivation cycle\n * @internal\n */\nexport function createDerivationChainContext(): DerivationChainContext {\n return {\n appliedDerivations: new Set(),\n iteration: 0,\n };\n}\n\n/**\n * Creates a unique key for a derivation entry.\n *\n * @param fieldKey - The field key (with array index already resolved)\n * @returns Unique key for the derivation\n * @internal\n */\nexport function createDerivationKey(fieldKey: string): string {\n return fieldKey;\n}\n\n/**\n * Parses a derivation key back into field key.\n *\n * @param key - The derivation key to parse\n * @returns Object containing the field key\n * @internal\n */\nexport function parseDerivationKey(key: string): { fieldKey: string } {\n return { fieldKey: key };\n}\n\n/** Result of processing all pending derivations. */\nexport interface DerivationProcessingResult {\n /** Number of derivations successfully applied */\n appliedCount: number;\n\n /** Number of derivations skipped (condition not met or value unchanged) */\n skippedCount: number;\n\n /** Number of derivations that failed with errors */\n errorCount: number;\n\n /** Number of derivations that encountered warnings (e.g., missing target field). */\n warnCount: number;\n\n /** Total iterations performed */\n iterations: number;\n\n /** Whether max iterations was reached (potential loop) */\n maxIterationsReached: boolean;\n}\n","import { DynamicFormError } from '@ng-forge/dynamic-forms/internal';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport { ExpressionParser } from '@ng-forge/dynamic-forms/internal';\nimport { BaseDerivationEntry } from './derivation-entry-base';\n\n/**\n * Inputs accepted by {@link computeValueFromEntry}.\n *\n * @internal\n */\nexport interface ComputeValueOptions {\n /** Registered derivation functions, used when the entry has `functionName`. */\n derivationFunctions?: Record<string, CustomFunction>;\n\n /**\n * Subject label used in the \"no value source\" error message — typically the\n * resolved field key (e.g. `'items.0.lineTotal'`) or `${fieldKey}.${targetProperty}`\n * for property derivations.\n */\n subject: string;\n\n /**\n * Optional descriptor (e.g., `'Property derivation function'`) used in the\n * \"function not found\" error. Defaults to `'Derivation function'`.\n */\n functionKind?: string;\n}\n\n/** True for a Promise/Observable wrongly returned into a sync derivation slot. */\nfunction isThenableOrObservable(value: unknown): boolean {\n if (value === null || typeof value !== 'object') return false;\n const candidate = value as { then?: unknown; subscribe?: unknown };\n return typeof candidate.then === 'function' || typeof candidate.subscribe === 'function';\n}\n\n/**\n * Single dispatch over `value` / `expression` / `functionName` shared by both\n * the value-derivation and property-derivation applicators.\n *\n * @internal\n */\nexport function computeValueFromEntry(\n entry: Pick<BaseDerivationEntry, 'value' | 'expression' | 'functionName' | 'fn'>,\n evalContext: EvaluationContext,\n options: ComputeValueOptions,\n): unknown {\n if (entry.value !== undefined) {\n return entry.value;\n }\n\n if (entry.expression) {\n return ExpressionParser.evaluate(entry.expression, evalContext);\n }\n\n if (entry.fn || entry.functionName) {\n if (entry.fn && entry.functionName) {\n evalContext.logger.warn(\n `Both \"fn\" and \"functionName\" are set on derivation for '${options.subject}'. Inline \"fn\" takes precedence; \"functionName\" is ignored.`,\n );\n }\n const fn = entry.fn ?? (entry.functionName ? options.derivationFunctions?.[entry.functionName] : undefined);\n if (!fn) {\n const kind = options.functionKind ?? 'Derivation function';\n throw new DynamicFormError(`${kind} '${entry.functionName}' not found in customFnConfig.derivations`);\n }\n const result = fn(evalContext);\n if (isThenableOrObservable(result)) {\n evalContext.logger.warn(\n `Derivation for '${options.subject}' returned a Promise or Observable. ` +\n `Synchronous derivations must return a value; use 'asyncDerivations' or an HTTP derivation for asynchronous logic. Skipping.`,\n );\n return undefined;\n }\n return result;\n }\n\n throw new DynamicFormError(\n `Derivation for ${options.subject} has no value source. Specify 'value', 'expression', 'functionName', or 'fn'.`,\n );\n}\n","import { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { ArrayPathInfo } from '../../utils/path-utils/path-utils';\n\n/**\n * Returns the path to the parent of the given field path. Drops the last\n * `.`-delimited segment. Returns `undefined` when the field has no parent\n * in scope (single-segment root key).\n *\n * @internal\n */\nexport function getParentPathInScope(fieldKey: string): string | undefined {\n const lastDot = fieldKey.lastIndexOf('.');\n if (lastDot <= 0) return undefined;\n return fieldKey.slice(0, lastDot);\n}\n\n/**\n * Per-array-item scope values resolved from a parsed array path.\n *\n * @internal\n */\nexport interface ArrayItemScope {\n /** Path within the array item (everything after `'.$.'`); `''` for non-array entries. */\n readonly relativePath: string;\n readonly groupValue: unknown;\n readonly fieldValue: unknown;\n}\n\n/**\n * Resolves the per-array-item scope shared by both derivation applicators.\n *\n * @internal\n */\nexport function resolveArrayItemScope(pathInfo: ArrayPathInfo, arrayItem: Record<string, unknown>): ArrayItemScope {\n const relativePath = pathInfo.isArrayPath ? pathInfo.relativePath : '';\n const innerParentPath = relativePath.includes('.') ? relativePath.slice(0, relativePath.lastIndexOf('.')) : '';\n const groupValue = innerParentPath ? getNestedValue(arrayItem, innerParentPath) : arrayItem;\n const fieldValue = relativePath ? getNestedValue(arrayItem, relativePath) : arrayItem;\n return { relativePath, groupValue, fieldValue };\n}\n","/**\n * Centralized constants for the derivation system.\n *\n * @module\n */\n\n/** Maximum number of derivation iterations before stopping to prevent infinite loops. */\nexport const MAX_DERIVATION_ITERATIONS = 10;\n\n/** Placeholder string for array index in derivation paths. */\nexport const ARRAY_PLACEHOLDER = '.$.';\n\n/** Maximum size of the expression AST cache. */\nexport const MAX_AST_CACHE_SIZE = 1000;\n\n/** Delimiter used to create unique derivation keys from source and target field keys. */\nexport const DERIVATION_KEY_DELIMITER = '\\x00';\n","import { isWritableSignal, untracked } from '@angular/core';\nimport type { FieldState, FieldTree } from '@angular/forms/signals';\nimport type { FieldTreeRecord } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport type { WarningTracker } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Navigates the form tree to resolve a field instance at the given path.\n *\n * @returns The field instance object, or `undefined` if the path is invalid\n * @internal\n */\nexport function resolveFieldInstance(rootForm: FieldTree<unknown>, fieldPath: string): FieldState<unknown> | undefined {\n const parts = fieldPath.split('.');\n let current = rootForm as FieldTreeRecord;\n\n for (let i = 0; i < parts.length - 1; i++) {\n const next = current[parts[i]];\n if (!next) return undefined;\n current = next as FieldTreeRecord;\n }\n\n const fieldAccessor = current[parts[parts.length - 1]];\n if (!fieldAccessor) return undefined;\n\n return untracked(fieldAccessor);\n}\n\n/**\n * Reads the dirty() signal from a field at the given path.\n *\n * @internal\n */\nexport function readFieldDirty(rootForm: FieldTree<unknown>, fieldPath: string): boolean | undefined {\n const fieldInstance = resolveFieldInstance(rootForm, fieldPath);\n if (!fieldInstance) return undefined;\n return untracked(fieldInstance.dirty);\n}\n\n/**\n * Resets a field's dirty/touched state at the given path.\n *\n * @internal\n */\nexport function resetFieldState(rootForm: FieldTree<unknown>, fieldPath: string): void {\n const fieldInstance = resolveFieldInstance(rootForm, fieldPath);\n if (!fieldInstance) return;\n fieldInstance.reset();\n}\n\n/**\n * Applies a value to the form at the specified path.\n *\n * @internal\n */\nexport function applyValueToForm(\n targetPath: string,\n value: unknown,\n rootForm: FieldTree<unknown>,\n logger?: Logger,\n warningTracker?: WarningTracker,\n): boolean {\n // Handle simple top-level fields\n if (!targetPath.includes('.')) {\n return setFieldValue(rootForm as FieldTreeRecord, targetPath, value, logger, warningTracker);\n }\n\n // Handle nested paths (e.g., 'address.city' or 'items.$.quantity')\n const parts = targetPath.split('.');\n let current = rootForm as FieldTreeRecord;\n\n for (let i = 0; i < parts.length - 1; i++) {\n const part = parts[i];\n\n // Handle array index placeholder '$'\n if (part === '$') {\n // This case should be resolved at collection time\n // If we still have '$', we need to skip for now\n return false;\n }\n\n // Navigate to the next level using bracket notation\n const next = current[part];\n if (!next) {\n warnMissingField(targetPath, logger, warningTracker);\n return false; // Path doesn't exist\n }\n current = next as FieldTreeRecord;\n }\n\n // Set the final value\n const finalPart = parts[parts.length - 1];\n return setFieldValue(current, finalPart, value, logger, warningTracker);\n}\n\n/**\n * Sets a field value using the Angular Signal Forms pattern.\n *\n * @returns true if the value was successfully set, false if field not found\n * @internal\n */\nfunction setFieldValue(\n parent: FieldTreeRecord,\n fieldKey: string,\n value: unknown,\n logger?: Logger,\n warningTracker?: WarningTracker,\n): boolean {\n const fieldAccessor = parent[fieldKey];\n\n if (!fieldAccessor) {\n warnMissingField(fieldKey, logger, warningTracker);\n return false;\n }\n\n // Call the FieldTree to get the FieldState\n const fieldInstance = untracked(fieldAccessor);\n\n if (!fieldInstance || typeof fieldInstance !== 'object') {\n warnMissingField(fieldKey, logger, warningTracker);\n return false;\n }\n\n // Get the value signal and verify it's a WritableSignal\n const valueSignal = fieldInstance.value;\n\n if (isWritableSignal(valueSignal)) {\n // Writing directly to value.set() does NOT trigger markAsDirty() —\n // only user interaction through setControlValue() does. So derivation-applied\n // values leave the field pristine, and dirty() reliably indicates user edits.\n valueSignal.set(value);\n return true;\n } else {\n warnMissingField(fieldKey, logger, warningTracker);\n return false;\n }\n}\n\n/**\n * Logs a warning for a missing target field (once per field per form instance).\n *\n * @internal\n */\nexport function warnMissingField(fieldKey: string, logger?: Logger, warningTracker?: WarningTracker): void {\n // If tracker is provided, check if we've already warned about this field\n if (warningTracker) {\n if (warningTracker.warnedKeys.has(fieldKey)) {\n return;\n }\n warningTracker.warnedKeys.add(fieldKey);\n }\n\n logger?.warn(\n `Derivation: target field '${fieldKey}' not found in form. ` +\n `Ensure the field is defined in your form configuration. ` +\n `This warning is shown once per field.`,\n );\n}\n","import { Signal, untracked } from '@angular/core';\nimport type { FieldTree } from '@angular/forms/signals';\nimport { ConditionalExpression } from '@ng-forge/dynamic-forms/internal';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport type { FieldStateInfo } from '@ng-forge/dynamic-forms/internal';\nimport type { FieldTreeRecord } from '@ng-forge/dynamic-forms/internal';\nimport { isEqual } from '@ng-forge/dynamic-forms/internal';\nimport { parseArrayPath, resolveArrayPath, isArrayPlaceholderPath } from '../../utils/path-utils/path-utils';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport { evaluateCondition } from '@ng-forge/dynamic-forms/internal';\nimport { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport {\n createDerivationChainContext,\n createDerivationKey,\n DerivationChainContext,\n DerivationCollection,\n DerivationEntry,\n DerivationProcessingResult,\n} from './derivation-types';\nimport type { WarningTracker } from '@ng-forge/dynamic-forms/internal';\nimport { computeValueFromEntry } from './compute-derived-value';\nimport { getParentPathInScope, resolveArrayItemScope } from './evaluation-scope';\nimport { MAX_DERIVATION_ITERATIONS } from './derivation-constants';\nimport { DerivationLogger } from './derivation-logger.service';\nimport { readFieldStateInfo, createFormFieldStateMap } from '@ng-forge/dynamic-forms/internal';\nimport { applyValueToForm, readFieldDirty, resetFieldState } from './field-value-utils';\n\n// Re-export for backwards compatibility\nexport type { DerivationProcessingResult } from './derivation-types';\n\n/** Context required for applying derivations. */\nexport interface DerivationApplicatorContext {\n /** The current form value (signal) */\n formValue: Signal<Record<string, unknown>>;\n\n /** Root form field tree for value updates */\n rootForm: FieldTree<unknown>;\n\n /** Registered custom derivation functions */\n derivationFunctions?: Record<string, CustomFunction>;\n\n /** Custom functions for expression evaluation */\n customFunctions?: Record<string, CustomFunction>;\n\n /**\n * External data resolved from form config signals.\n * Made available in evaluation context as `externalData`.\n */\n externalData?: Record<string, unknown>;\n\n /** Logger for diagnostic output */\n logger: Logger;\n\n /**\n * Instance-scoped warning tracker to prevent log spam.\n * If not provided, warnings will be logged every time.\n */\n warningTracker?: WarningTracker;\n\n /**\n * Derivation logger for debug output.\n * Created via `createDerivationLogger()` factory.\n */\n derivationLogger: DerivationLogger;\n\n /**\n * Maximum number of iterations for derivation chain processing.\n * Falls back to MAX_DERIVATION_ITERATIONS constant if not provided.\n */\n maxIterations?: number;\n}\n\n/**\n * Result of a single derivation application.\n *\n * @internal\n */\ninterface DerivationResult {\n /** Whether the derivation was applied */\n applied: boolean;\n\n /** The field key */\n fieldKey: string;\n\n /** The computed value */\n newValue?: unknown;\n\n /** Error message if application failed */\n error?: string;\n\n /** Warning message (e.g., missing target field) - not a failure, but noteworthy */\n warning?: string;\n}\n\n/**\n * Applies all pending derivations from a collection.\n *\n * @param collection - The collected derivation entries\n * @param context - Context for applying derivations\n * @param changedFields - Set of field keys that changed (for filtering)\n * @returns Result of the derivation processing\n */\nexport function applyDerivations(\n collection: DerivationCollection,\n context: DerivationApplicatorContext,\n changedFields?: Set<string>,\n): DerivationProcessingResult {\n const chainContext = createDerivationChainContext();\n chainContext.changedFields = changedFields;\n // Cache formFieldState map per cycle to avoid allocating a new Proxy + Map per entry\n chainContext.formFieldState = createFormFieldStateMap(context.rootForm, false);\n const { derivationLogger } = context;\n const maxIterations = context.maxIterations ?? MAX_DERIVATION_ITERATIONS;\n let appliedCount = 0;\n let skippedCount = 0;\n let errorCount = 0;\n let warnCount = 0;\n\n // Filter entries based on changed fields\n const entriesToProcess = changedFields ? getEntriesForChangedFields(collection.entries, changedFields) : collection.entries;\n\n // Log cycle start in verbose mode\n derivationLogger.cycleStart('onChange', entriesToProcess.length);\n\n // Process derivations iteratively until no more changes\n let hasChanges = true;\n\n while (hasChanges && chainContext.iteration < maxIterations) {\n chainContext.iteration++;\n hasChanges = false;\n\n // Log iteration in verbose mode\n derivationLogger.iteration(chainContext.iteration);\n\n for (const entry of entriesToProcess) {\n const result = tryApplyDerivation(entry, context, chainContext);\n\n if (result.applied) {\n appliedCount++;\n hasChanges = true;\n } else if (result.error) {\n errorCount++;\n } else if (result.warning) {\n warnCount++;\n skippedCount++;\n } else {\n skippedCount++;\n }\n }\n }\n\n // Build result\n const processingResult: DerivationProcessingResult = {\n appliedCount,\n skippedCount,\n errorCount,\n warnCount,\n iterations: chainContext.iteration,\n maxIterationsReached: chainContext.iteration >= maxIterations,\n };\n\n if (processingResult.maxIterationsReached) {\n derivationLogger.maxIterationsReached(processingResult, 'onChange');\n }\n\n // Log summary\n derivationLogger.summary(processingResult, 'onChange');\n\n return processingResult;\n}\n\n/**\n * Gets entries that should be processed based on changed fields.\n *\n * @internal\n */\nfunction getEntriesForChangedFields(entries: DerivationEntry[], changedFields: Set<string>): DerivationEntry[] {\n return entries.filter((entry) => {\n // Entries with no dependencies or wildcard dependencies always run\n if (entry.dependsOn.length === 0 || entry.dependsOn.includes('*')) {\n return true;\n }\n\n // Check if any dependency matches a changed field directly\n if (entry.dependsOn.some((dep) => changedFields.has(dep))) {\n return true;\n }\n\n // For array entries (fieldKey contains '$'), check if the entry's parent\n // array key changed. Array derivations have relative dependencies\n // (e.g., 'quantity') but changedFields contains root keys (e.g., 'lineItems').\n for (const changed of changedFields) {\n // Check if this entry lives under a changed parent (array or nested)\n if (entry.fieldKey.startsWith(changed + '.')) return true;\n // Check if a dependency is nested under a changed parent\n if (entry.dependsOn.some((dep) => dep.startsWith(changed + '.'))) return true;\n }\n\n return false;\n });\n}\n\n/**\n * Attempts to apply a single derivation.\n *\n * @internal\n */\nfunction tryApplyDerivation(\n entry: DerivationEntry,\n context: DerivationApplicatorContext,\n chainContext: DerivationChainContext,\n): DerivationResult {\n const { derivationLogger } = context;\n\n // HTTP and async entries are processed asynchronously in their own streams — skip in sync loop\n if (entry.http || entry.asyncFunctionName || entry.asyncFn) {\n return { applied: false, fieldKey: entry.fieldKey };\n }\n\n // Check if this is an array field derivation (has '$' placeholder)\n if (isArrayPlaceholderPath(entry.fieldKey)) {\n return tryApplyArrayDerivation(entry, context, chainContext);\n }\n\n const derivationKey = createDerivationKey(entry.fieldKey);\n\n // Check if already applied in this cycle\n if (chainContext.appliedDerivations.has(derivationKey)) {\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'already-applied',\n });\n return { applied: false, fieldKey: entry.fieldKey };\n }\n\n // Check stopOnUserOverride — skip if the user has manually edited the target field\n if (shouldSkipForUserOverride(entry, entry.fieldKey, context, chainContext)) {\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'user-override',\n });\n return { applied: false, fieldKey: entry.fieldKey };\n }\n\n // Create evaluation context\n const formValue = untracked(() => context.formValue());\n const evalContext = createEvaluationContext(entry, formValue, context, chainContext);\n\n // Evaluate condition\n if (!evaluateDerivationCondition(entry.condition, evalContext)) {\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'condition-false',\n });\n return { applied: false, fieldKey: entry.fieldKey };\n }\n\n // Compute derived value\n let newValue: unknown;\n try {\n newValue = computeDerivedValue(entry, evalContext, context);\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n context.logger.error(formatDerivationError(entry, 'compute', errorMessage));\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'error',\n error: errorMessage,\n });\n return {\n applied: false,\n fieldKey: entry.fieldKey,\n error: errorMessage,\n };\n }\n\n // Check if value actually changed using exact equality.\n // Note: This uses isEqual which performs deep comparison with exact IEEE 754\n // equality for numbers. Bidirectional derivations with floating-point math\n // may oscillate due to rounding errors. Use explicit rounding in expressions\n // or integer arithmetic to avoid this issue.\n const currentValue = getNestedValue(formValue, entry.fieldKey);\n if (isEqual(currentValue, newValue)) {\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'value-unchanged',\n });\n return { applied: false, fieldKey: entry.fieldKey };\n }\n\n // Apply the value\n try {\n const wasApplied = applyValueToForm(entry.fieldKey, newValue, context.rootForm, context.logger, context.warningTracker);\n\n if (wasApplied) {\n chainContext.appliedDerivations.add(derivationKey);\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'applied',\n previousValue: currentValue,\n newValue,\n });\n return { applied: true, fieldKey: entry.fieldKey, newValue };\n } else {\n // Field not found - this is a warning, not an error\n // The warning was already logged by warnMissingField\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'target-not-found',\n });\n return {\n applied: false,\n fieldKey: entry.fieldKey,\n warning: `Field '${entry.fieldKey}' not found`,\n };\n }\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n context.logger.error(formatDerivationError(entry, 'apply', errorMessage));\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'error',\n error: errorMessage,\n });\n return {\n applied: false,\n fieldKey: entry.fieldKey,\n error: errorMessage,\n };\n }\n}\n\n/**\n * Handles array field derivations with '$' placeholder.\n *\n * @internal\n */\nfunction tryApplyArrayDerivation(\n entry: DerivationEntry,\n context: DerivationApplicatorContext,\n chainContext: DerivationChainContext,\n): DerivationResult {\n const formValue = untracked(() => context.formValue());\n\n // Parse the field path using path utilities\n const pathInfo = parseArrayPath(entry.fieldKey);\n if (!pathInfo.isArrayPath) {\n return { applied: false, fieldKey: entry.fieldKey, error: 'Invalid array derivation path' };\n }\n\n const { arrayPath } = pathInfo;\n\n // Get the array from form values\n const arrayValue = getNestedValue(formValue, arrayPath);\n if (!Array.isArray(arrayValue)) {\n return { applied: false, fieldKey: entry.fieldKey };\n }\n\n let appliedAny = false;\n\n // Process each array item\n for (let i = 0; i < arrayValue.length; i++) {\n const resolvedPath = resolveArrayPath(entry.fieldKey, i);\n const derivationKey = createDerivationKey(resolvedPath);\n\n // Skip if already applied in this cycle\n if (chainContext.appliedDerivations.has(derivationKey)) {\n continue;\n }\n\n // Check stopOnUserOverride for array items using dirty() signal\n if (shouldSkipForUserOverride(entry, resolvedPath, context, chainContext)) {\n context.derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: resolvedPath,\n result: 'skipped',\n skipReason: 'user-override',\n });\n continue;\n }\n\n // Create evaluation context scoped to this array item\n const arrayItem = arrayValue[i] as Record<string, unknown>;\n const evalContext = createArrayItemEvaluationContext(entry, arrayItem, formValue, i, arrayPath, context, chainContext);\n\n // Evaluate condition\n if (!evaluateDerivationCondition(entry.condition, evalContext)) {\n continue;\n }\n\n // Compute derived value\n let newValue: unknown;\n try {\n newValue = computeDerivedValue(entry, evalContext, context);\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n context.logger.error(formatDerivationError(entry, 'compute', `[index ${i}] ${errorMessage}`));\n continue;\n }\n\n // Check if value actually changed\n const currentValue = getNestedValue(formValue, resolvedPath);\n if (isEqual(currentValue, newValue)) {\n continue;\n }\n\n // Apply the value\n try {\n applyValueToForm(resolvedPath, newValue, context.rootForm, context.logger, context.warningTracker);\n chainContext.appliedDerivations.add(derivationKey);\n appliedAny = true;\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n context.logger.error(formatDerivationError(entry, 'apply', `[index ${i}] ${errorMessage}`));\n }\n }\n\n return { applied: appliedAny, fieldKey: entry.fieldKey };\n}\n\n/**\n * Creates an evaluation context for derivation processing.\n *\n * @internal\n */\nfunction createEvaluationContext(\n entry: DerivationEntry,\n formValue: Record<string, unknown>,\n context: DerivationApplicatorContext,\n chainContext: DerivationChainContext,\n): EvaluationContext {\n const fieldValue = getNestedValue(formValue, entry.fieldKey);\n\n // Create field state snapshot for this specific field (non-reactive)\n const fieldAccessor = (context.rootForm as FieldTreeRecord)[entry.fieldKey];\n const fieldState = fieldAccessor ? readFieldStateInfo(fieldAccessor, false) : undefined;\n\n // Parent group value: the absolute path with the trailing field segment\n // dropped. `undefined` when the field is at form root.\n const parentPath = getParentPathInScope(entry.fieldKey);\n const groupValue = parentPath ? getNestedValue(formValue, parentPath) : undefined;\n\n return {\n fieldValue,\n formValue,\n fieldPath: entry.fieldKey,\n groupValue,\n customFunctions: context.customFunctions,\n externalData: context.externalData,\n logger: context.logger,\n fieldState,\n formFieldState: chainContext.formFieldState,\n };\n}\n\n/**\n * Creates an evaluation context scoped to a specific array item.\n *\n * @internal\n */\nfunction createArrayItemEvaluationContext(\n entry: DerivationEntry,\n arrayItem: Record<string, unknown>,\n rootFormValue: Record<string, unknown>,\n itemIndex: number,\n arrayPath: string,\n context: DerivationApplicatorContext,\n chainContext: DerivationChainContext,\n): EvaluationContext {\n // For array item expressions, formValue should be the array item\n // This allows expressions like 'formValue.quantity * formValue.unitPrice'\n // to work within the context of each array item\n\n // Create field state snapshot for the specific array item field\n // Navigate: rootForm[arrayPath][index][fieldKey]\n const pathInfo = parseArrayPath(entry.fieldKey);\n let fieldState: FieldStateInfo | undefined;\n\n if (pathInfo.isArrayPath && pathInfo.relativePath) {\n const rootFormRecord = context.rootForm as FieldTreeRecord;\n const arrayAccessor = rootFormRecord[arrayPath];\n if (arrayAccessor) {\n const arrayItems = arrayAccessor as FieldTreeRecord;\n const itemAccessor = arrayItems[String(itemIndex)];\n if (itemAccessor) {\n const itemFields = itemAccessor as FieldTreeRecord;\n const fieldAccessor = itemFields[pathInfo.relativePath];\n if (fieldAccessor) {\n fieldState = readFieldStateInfo(fieldAccessor, false);\n }\n }\n }\n }\n\n const { relativePath, groupValue, fieldValue } = resolveArrayItemScope(pathInfo, arrayItem);\n\n return {\n fieldValue,\n formValue: arrayItem,\n fieldPath: relativePath ? `${arrayPath}.${itemIndex}.${relativePath}` : `${arrayPath}.${itemIndex}`,\n groupValue,\n customFunctions: context.customFunctions,\n externalData: context.externalData,\n logger: context.logger,\n // Provide access to root form value for cross-scope references\n rootFormValue,\n arrayIndex: itemIndex,\n arrayPath,\n fieldState,\n formFieldState: chainContext.formFieldState,\n };\n}\n\n/**\n * Evaluates the derivation condition.\n *\n * @internal\n */\nfunction evaluateDerivationCondition(condition: ConditionalExpression | boolean, context: EvaluationContext): boolean {\n if (typeof condition === 'boolean') {\n return condition;\n }\n\n return evaluateCondition(condition, context);\n}\n\n/**\n * Computes the derived value based on the entry configuration.\n *\n * @internal\n */\nfunction computeDerivedValue(\n entry: DerivationEntry,\n evalContext: EvaluationContext,\n applicatorContext: DerivationApplicatorContext,\n): unknown {\n return computeValueFromEntry(entry, evalContext, {\n derivationFunctions: applicatorContext.derivationFunctions,\n subject: entry.fieldKey,\n });\n}\n\n/**\n * Error message prefix for derivation-related errors.\n *\n * @internal\n */\nconst ERROR_PREFIX = '[Derivation]';\n\n/**\n * Creates a standardized error message for derivation errors.\n *\n * @internal\n */\nfunction formatDerivationError(entry: DerivationEntry, phase: 'compute' | 'apply' | 'condition', message: string): string {\n const source = entry.expression\n ? `expression: \"${entry.expression.substring(0, 50)}${entry.expression.length > 50 ? '...' : ''}\"`\n : entry.functionName\n ? `function: \"${entry.functionName}\"`\n : entry.fn\n ? 'inline fn'\n : entry.value !== undefined\n ? 'static value'\n : 'unknown source';\n\n return `${ERROR_PREFIX} Failed to ${phase} derivation (field: ${entry.fieldKey}, ${source}): ${message}`;\n}\n\n/**\n * Checks whether any of the entry's dependencies appear in the changed fields set.\n *\n * @internal\n */\nfunction hasDependencyChanged(entry: DerivationEntry, changedFields: Set<string>): boolean {\n return entry.dependsOn.some((dep) => dep === '*' || changedFields.has(dep));\n}\n\n/**\n * Checks whether a derivation should be skipped due to the user having manually\n * edited the target field. Handles re-engagement when `reEngageOnDependencyChange`\n * is set and a dependency has changed.\n *\n * @returns `true` if the derivation should be skipped, `false` otherwise\n * @internal\n */\nfunction shouldSkipForUserOverride(\n entry: DerivationEntry,\n resolvedFieldKey: string,\n context: DerivationApplicatorContext,\n chainContext: DerivationChainContext,\n): boolean {\n if (!entry.stopOnUserOverride) return false;\n\n const isDirty = readFieldDirty(context.rootForm, resolvedFieldKey) ?? false;\n\n // If the field isn't dirty, no override to skip — and no re-engagement needed.\n // This also avoids wasteful resetFieldState calls on initial form render where\n // changedFields contains all keys (from startWith(null) + pairwise()) but fields\n // are all pristine.\n if (!isDirty) return false;\n\n // Re-engagement: reset dirty state if a dependency changed, allowing re-derivation\n if (entry.reEngageOnDependencyChange && chainContext.changedFields) {\n if (hasDependencyChanged(entry, chainContext.changedFields)) {\n resetFieldState(context.rootForm, resolvedFieldKey);\n // Re-read after reset — field is now pristine, so derivation proceeds\n return false;\n }\n }\n\n // Field is dirty and no re-engagement triggered — skip derivation\n return true;\n}\n\n/**\n * Processes derivations for a specific trigger type.\n *\n * @param collection - The collected derivation entries\n * @param trigger - The trigger type to filter by\n * @param context - Context for applying derivations\n * @param changedFields - Set of field keys that changed (for filtering)\n * @returns Result of the derivation processing\n */\nexport function applyDerivationsForTrigger(\n collection: DerivationCollection,\n trigger: 'onChange' | 'debounced',\n context: DerivationApplicatorContext,\n changedFields?: Set<string>,\n): DerivationProcessingResult {\n // Filter entries by trigger type, excluding HTTP and async entries (processed in async streams)\n const filteredEntries = collection.entries.filter((entry) => {\n if (entry.http || entry.asyncFunctionName || entry.asyncFn) return false;\n if (trigger === 'onChange') {\n return !entry.trigger || entry.trigger === 'onChange';\n }\n return entry.trigger === trigger;\n });\n\n // Create a minimal collection with filtered entries\n const filteredCollection: DerivationCollection = {\n entries: filteredEntries,\n };\n\n return applyDerivations(filteredCollection, context, changedFields);\n}\n\n/**\n * Gets all debounced derivation entries from a collection.\n *\n * @param collection - The collected derivation entries\n * @returns Array of debounced derivation entries\n */\nexport function getDebouncedDerivationEntries(collection: DerivationCollection): DerivationEntry[] {\n return collection.entries.filter((entry) => entry.trigger === 'debounced');\n}\n","import { DerivationEntry } from './derivation-types';\n\n/**\n * Sorts derivation entries in topological order based on their dependencies.\n *\n * @param entries - The derivation entries to sort\n * @returns A new array of entries in topological order\n */\nexport function topologicalSort(entries: DerivationEntry[]): DerivationEntry[] {\n if (entries.length <= 1) {\n return [...entries];\n }\n\n // Build adjacency list and in-degree count\n // An edge A -> B exists if B depends on the target of A\n const graph = new Map<DerivationEntry, Set<DerivationEntry>>();\n const inDegree = new Map<DerivationEntry, number>();\n\n // Initialize\n for (const entry of entries) {\n graph.set(entry, new Set());\n inDegree.set(entry, 0);\n }\n\n // Pre-build index: field key -> entries that produce/target it\n // This enables O(1) lookup instead of O(n) scanning\n const derivationsByField = new Map<string, DerivationEntry[]>();\n for (const entry of entries) {\n // Index by exact field key\n const fieldEntries = derivationsByField.get(entry.fieldKey) ?? [];\n fieldEntries.push(entry);\n derivationsByField.set(entry.fieldKey, fieldEntries);\n\n // Also index array field relative paths for array derivations\n // If entry targets 'items.$.lineTotal', index under 'lineTotal' too\n if (entry.fieldKey.includes('.$.')) {\n const relativePath = entry.fieldKey.split('.$.')[1];\n if (relativePath) {\n const relativeEntries = derivationsByField.get(relativePath) ?? [];\n relativeEntries.push(entry);\n derivationsByField.set(relativePath, relativeEntries);\n }\n }\n }\n\n // Collect entries with wildcard dependencies (need special handling)\n const wildcardDependents: DerivationEntry[] = [];\n for (const entry of entries) {\n if (entry.dependsOn.includes('*')) {\n wildcardDependents.push(entry);\n }\n }\n\n // Build edges using pre-computed index\n // For each entry B, find all entries A whose field is in B's dependencies\n // If B depends on field X, and A produces X, then A must run before B\n for (const entryB of entries) {\n for (const dep of entryB.dependsOn) {\n // Skip wildcards here - handled separately below\n if (dep === '*') continue;\n\n // O(1) lookup: find all derivations that produce this dependency\n const producers = derivationsByField.get(dep);\n if (producers) {\n for (const entryA of producers) {\n const entryAEdges = graph.get(entryA);\n if (entryA !== entryB && entryAEdges && !entryAEdges.has(entryB)) {\n entryAEdges.add(entryB);\n inDegree.set(entryB, (inDegree.get(entryB) ?? 0) + 1);\n }\n }\n }\n }\n }\n\n // Handle wildcard dependencies: they depend on ALL producers\n for (const entryB of wildcardDependents) {\n for (const entryA of entries) {\n const entryAEdges = graph.get(entryA);\n if (entryA !== entryB && entryAEdges && !entryAEdges.has(entryB)) {\n entryAEdges.add(entryB);\n inDegree.set(entryB, (inDegree.get(entryB) ?? 0) + 1);\n }\n }\n }\n\n // Kahn's algorithm: process nodes with in-degree 0\n const queue: DerivationEntry[] = [];\n const sorted: DerivationEntry[] = [];\n\n // Start with entries that have no dependencies on other derivation outputs\n for (const entry of entries) {\n if (inDegree.get(entry) === 0) {\n queue.push(entry);\n }\n }\n\n while (queue.length > 0) {\n const current = queue.shift();\n if (!current) break; // TypeScript guard (shouldn't happen due to while condition)\n sorted.push(current);\n\n // Reduce in-degree for all neighbors\n const neighbors = graph.get(current) ?? new Set<DerivationEntry>();\n for (const neighbor of neighbors) {\n const newDegree = (inDegree.get(neighbor) ?? 0) - 1;\n inDegree.set(neighbor, newDegree);\n\n if (newDegree === 0) {\n queue.push(neighbor);\n }\n }\n }\n\n // If we couldn't sort all entries, there's a cycle\n // (but cycles should be caught by cycle-detector, so this is defensive)\n if (sorted.length !== entries.length) {\n // Return original order for entries in cycles\n const sortedSet = new Set(sorted);\n for (const entry of entries) {\n if (!sortedSet.has(entry)) {\n sorted.push(entry);\n }\n }\n }\n\n return sorted;\n}\n","import { DerivationLogicConfig } from '@ng-forge/dynamic-forms/internal';\nimport { extractExpressionDependencies, extractStringDependencies } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Extracts the union of field dependencies declared by a derivation config.\n *\n * @internal\n */\nexport function extractDependenciesFromConfig(config: DerivationLogicConfig): string[] {\n const deps = new Set<string>();\n\n if (config.dependsOn && config.dependsOn.length > 0) {\n config.dependsOn.forEach((dep) => deps.add(dep));\n } else {\n if (config.expression) {\n extractStringDependencies(config.expression).forEach((dep) => deps.add(dep));\n }\n // Inline `fn` mirrors `functionName` semantics: without explicit dependsOn,\n // assume the function may depend on anything in the form.\n if (config.functionName || (config as { fn?: unknown }).fn) {\n deps.add('*');\n }\n }\n\n if (config.condition && config.condition !== true) {\n extractExpressionDependencies(config.condition).forEach((dep) => deps.add(dep));\n }\n\n return Array.from(deps);\n}\n","import type { AsyncDerivationFunction } from '@ng-forge/dynamic-forms/internal';\nimport type { CustomFunction } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Reads the optional inline `fn` property off a derivation config.\n *\n * @internal\n */\nexport function extractInlineFn(config: object): CustomFunction | undefined {\n return (config as { fn?: CustomFunction }).fn;\n}\n\n/**\n * Reads the optional inline `asyncFn` property off a derivation config.\n *\n * @internal\n */\nexport function extractInlineAsyncFn(config: object): AsyncDerivationFunction | undefined {\n return (config as { asyncFn?: AsyncDerivationFunction }).asyncFn;\n}\n","import { DynamicFormError } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Tokens recognised by the dependency resolver. Exported so the few callers\n * that build `dependsOn` arrays programmatically (e.g. tests) don't have to\n * inline the string literal.\n */\nexport const SELF_DEPENDENCY_TOKEN = '$self';\n\n/**\n * Token resolving to the absolute path of the field's nearest parent\n * container (group or array).\n */\nexport const GROUP_DEPENDENCY_TOKEN = '$group';\n\n/**\n * Context threaded through {@link traverseFieldsWithContext} during derivation\n * collection. Tracks array + group ancestry so absolute field paths and\n * `$self` / `$group` tokens can be resolved without re-walking the tree.\n *\n * @internal\n */\nexport interface CollectionContext {\n /** Path to the enclosing array (e.g. `'items'` or `'orders.0.lineItems'`). */\n arrayPath?: string;\n /**\n * Dot-joined ancestor group keys scoped to the current array item (or\n * top-level when not inside an array). Reset at array boundaries — the\n * array placeholder path takes over, and group ancestors OUTSIDE an\n * array don't contribute to descendants' entry keys.\n */\n groupPath?: string;\n}\n\n/**\n * Builds the absolute field path for an entry whose enclosing context is\n * tracked by {@link CollectionContext}.\n *\n * @internal\n */\nexport function buildEffectiveFieldKey(fieldKey: string, context: CollectionContext): string {\n if (context.arrayPath && context.groupPath) {\n return `${context.arrayPath}.$.${context.groupPath}.${fieldKey}`;\n }\n if (context.arrayPath) {\n return `${context.arrayPath}.$.${fieldKey}`;\n }\n if (context.groupPath) {\n return `${context.groupPath}.${fieldKey}`;\n }\n return fieldKey;\n}\n\n/**\n * Resolves the absolute path of the field's nearest parent container\n * (group or array). Returns `undefined` when the field is at the form root\n * with no parent container.\n *\n * @internal\n */\nexport function buildNearestGroupPath(context: CollectionContext): string | undefined {\n if (context.arrayPath && context.groupPath) {\n return `${context.arrayPath}.$.${context.groupPath}`;\n }\n return context.arrayPath ?? context.groupPath;\n}\n\n/**\n * Substitutes `$self` / `$self.X` / `$group` / `$group.X` tokens in a\n * `dependsOn` array with their resolved absolute paths. Non-token strings\n * pass through unchanged.\n *\n * @internal\n */\nexport function resolveTokenDependencies(deps: string[], effectiveFieldKey: string, context: CollectionContext): string[] {\n return deps.map((dep) => {\n if (dep === SELF_DEPENDENCY_TOKEN) {\n return effectiveFieldKey;\n }\n if (dep.startsWith(`${SELF_DEPENDENCY_TOKEN}.`)) {\n return effectiveFieldKey + dep.slice(SELF_DEPENDENCY_TOKEN.length);\n }\n if (dep === GROUP_DEPENDENCY_TOKEN || dep.startsWith(`${GROUP_DEPENDENCY_TOKEN}.`)) {\n const groupPath = buildNearestGroupPath(context);\n if (!groupPath) {\n throw new DynamicFormError(\n `Derivation for '${effectiveFieldKey}' uses '${dep}' but the field has no parent group or array. ` +\n `'${GROUP_DEPENDENCY_TOKEN}' is only valid for fields nested inside a group or array container.`,\n );\n }\n if (dep === GROUP_DEPENDENCY_TOKEN) return groupPath;\n return groupPath + dep.slice(GROUP_DEPENDENCY_TOKEN.length);\n }\n return dep;\n });\n}\n\n/**\n * Standard `traverseFieldsWithContext` step handlers for derivation\n * collection.\n *\n * @internal\n */\nexport const CONTEXT_TRAVERSAL_OPTIONS = {\n onArrayChild: (_parent: CollectionContext, field: { key: string }) => ({ arrayPath: field.key, groupPath: undefined }),\n onGroupChild: (parent: CollectionContext, field: { key?: string }) => {\n if (!field.key) return {};\n return { groupPath: parent.groupPath ? `${parent.groupPath}.${field.key}` : field.key };\n },\n};\n","import { FieldDef } from '@ng-forge/dynamic-forms/internal';\nimport { FieldWithValidation } from '@ng-forge/dynamic-forms/internal';\nimport { DynamicFormError } from '@ng-forge/dynamic-forms/internal';\nimport { DerivationLogicConfig, hasTargetProperty, isDerivationLogicConfig } from '@ng-forge/dynamic-forms/internal';\nimport { extractStringDependencies } from '@ng-forge/dynamic-forms/internal';\nimport { PropertyDerivationCollection, PropertyDerivationEntry } from '../property-derivation/property-derivation-types';\nimport { topologicalSort } from './derivation-sorter';\nimport { DerivationCollection, DerivationEntry } from './derivation-types';\nimport { extractDependenciesFromConfig } from './extract-dependencies';\nimport { extractInlineAsyncFn, extractInlineFn } from './extract-inline-fn';\nimport { traverseFieldsWithContext } from './field-traversal';\nimport { buildEffectiveFieldKey, CollectionContext, CONTEXT_TRAVERSAL_OPTIONS, resolveTokenDependencies } from './collection-context';\n\n// Re-export the dependency tokens for back-compat with external imports.\nexport { SELF_DEPENDENCY_TOKEN, GROUP_DEPENDENCY_TOKEN } from './collection-context';\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Public collectors\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Collects all VALUE derivation entries from field definitions.\n *\n * @param fields - Array of field definitions to traverse\n * @returns Collection containing sorted derivation entries\n */\nexport function collectDerivations(fields: FieldDef<unknown>[]): DerivationCollection {\n const entries: DerivationEntry[] = [];\n\n traverseFieldsWithContext<CollectionContext>(\n fields,\n {},\n (field, context) => collectValueEntriesFromField(field, entries, context),\n CONTEXT_TRAVERSAL_OPTIONS,\n );\n\n return { entries: topologicalSort(entries) };\n}\n\n/**\n * Single traversal that emits both value derivations (sorted topologically)\n * and property derivations (no sort — they don't chain among themselves).\n *\n * @internal\n */\nexport function collectAllDerivations(fields: FieldDef<unknown>[]): {\n value: DerivationCollection;\n property: PropertyDerivationCollection;\n} {\n const valueEntries: DerivationEntry[] = [];\n const propertyEntries: PropertyDerivationEntry[] = [];\n\n traverseFieldsWithContext<CollectionContext>(\n fields,\n {},\n (field, context) => {\n collectValueEntriesFromField(field, valueEntries, context);\n collectPropertyEntriesFromField(field, propertyEntries, context);\n },\n CONTEXT_TRAVERSAL_OPTIONS,\n );\n\n return {\n value: { entries: topologicalSort(valueEntries) },\n property: { entries: propertyEntries },\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Per-field handlers\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Collects value-derivation entries (shorthand + `logic[]` without\n * `targetProperty`) from a single field.\n *\n * @internal\n */\nfunction collectValueEntriesFromField(field: FieldDef<unknown>, entries: DerivationEntry[], context: CollectionContext): void {\n const fieldKey = field.key;\n if (!fieldKey) return;\n\n const validationField = field as FieldDef<unknown> & FieldWithValidation;\n\n if (validationField.derivation) {\n entries.push(createShorthandEntry(fieldKey, validationField.derivation, context));\n }\n\n if (validationField.logic) {\n for (const logicConfig of validationField.logic) {\n if (!isDerivationLogicConfig(logicConfig)) continue;\n if (hasTargetProperty(logicConfig)) continue; // property entries handled by collectPropertyEntriesFromField\n entries.push(createValueLogicEntry(fieldKey, logicConfig, context));\n }\n }\n}\n\n/**\n * Collects property-derivation entries (`logic[]` with `targetProperty`) from\n * a single field.\n *\n * @internal\n */\nfunction collectPropertyEntriesFromField(field: FieldDef<unknown>, entries: PropertyDerivationEntry[], context: CollectionContext): void {\n const fieldKey = field.key;\n if (!fieldKey) return;\n\n const validationField = field as FieldDef<unknown> & FieldWithValidation;\n\n if (!validationField.logic) return;\n\n for (const logicConfig of validationField.logic) {\n if (!isDerivationLogicConfig(logicConfig)) continue;\n if (!hasTargetProperty(logicConfig)) continue;\n entries.push(createPropertyLogicEntry(fieldKey, logicConfig, context));\n }\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Entry builders\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Creates a value-derivation entry from a field's shorthand `derivation`\n * property.\n *\n * @internal\n */\nfunction createShorthandEntry(fieldKey: string, expression: string, context: CollectionContext): DerivationEntry {\n const effectiveFieldKey = buildEffectiveFieldKey(fieldKey, context);\n\n return {\n fieldKey: effectiveFieldKey,\n dependsOn: extractStringDependencies(expression),\n condition: true,\n expression,\n trigger: 'onChange',\n isShorthand: true,\n };\n}\n\n/**\n * Creates a value-derivation entry from a `logic[]` `DerivationLogicConfig`\n * that does NOT have `targetProperty`.\n *\n * @internal\n */\nfunction createValueLogicEntry(fieldKey: string, config: DerivationLogicConfig, context: CollectionContext): DerivationEntry {\n const effectiveFieldKey = buildEffectiveFieldKey(fieldKey, context);\n\n // Runtime guards for HTTP and async derivations.\n // Mutual exclusivity and required fields are now enforced by TypeScript (via `source` discriminant).\n // The wildcard and empty-array checks below are not expressible in the type system.\n //\n // Note: `$group` / `$group.X` tokens are intentionally allowed for HTTP and async sources even\n // though they resolve to the parent path and therefore fire on any sibling change inside that\n // group/array. Unlike `*`, the token is structurally scoped (collection throws if the field has\n // no parent container) and an explicit opt-in. Consumers that want to bound request frequency\n // can use `trigger: 'debounced'` here, or rely on debouncing inside their async function.\n if (config.source === 'http') {\n if (config.dependsOn.length === 0) {\n throw new DynamicFormError(\n `HTTP derivation for '${effectiveFieldKey}' requires explicit 'dependsOn'. ` +\n `Wildcard dependencies would trigger HTTP requests on every form change.`,\n );\n }\n if (config.dependsOn.includes('*')) {\n throw new DynamicFormError(\n `HTTP derivation for '${effectiveFieldKey}' cannot use wildcard ('*') in 'dependsOn'. ` +\n `Wildcards would trigger HTTP requests on every form change. Specify explicit field dependencies instead.`,\n );\n }\n }\n\n if (config.source === 'asyncFunction') {\n if (config.dependsOn.length === 0) {\n throw new DynamicFormError(\n `Async derivation for '${effectiveFieldKey}' requires explicit 'dependsOn'. ` +\n `Wildcard dependencies would trigger async functions on every form change.`,\n );\n }\n if (config.dependsOn.includes('*')) {\n throw new DynamicFormError(\n `Async derivation for '${effectiveFieldKey}' cannot use wildcard ('*') in 'dependsOn'. ` +\n `Wildcards would trigger async functions on every form change. Specify explicit field dependencies instead.`,\n );\n }\n }\n\n const dependsOn = resolveTokenDependencies(extractDependenciesFromConfig(config), effectiveFieldKey, context);\n const condition = config.condition ?? true;\n const trigger = config.trigger ?? 'onChange';\n const debounceMs = trigger === 'debounced' ? (config as { debounceMs?: number }).debounceMs : undefined;\n\n return {\n fieldKey: effectiveFieldKey,\n dependsOn,\n condition,\n value: config.value,\n expression: config.expression,\n functionName: config.functionName,\n fn: extractInlineFn(config),\n http: config.http,\n responseExpression: config.responseExpression,\n asyncFunctionName: config.asyncFunctionName,\n asyncFn: extractInlineAsyncFn(config),\n trigger,\n debounceMs,\n isShorthand: false,\n originalConfig: config,\n debugName: config.debugName,\n stopOnUserOverride: config.stopOnUserOverride,\n reEngageOnDependencyChange: config.reEngageOnDependencyChange,\n };\n}\n\n/**\n * Creates a property-derivation entry from a `logic[]` `DerivationLogicConfig`\n * that has `targetProperty`. Effective key uses the same array/group ancestry\n * encoding as value entries via {@link buildEffectiveFieldKey} —\n * `buildPropertyOverrideKey` in the original property collector produced the\n * same shape.\n *\n * @internal\n */\nfunction createPropertyLogicEntry(\n fieldKey: string,\n config: DerivationLogicConfig & { targetProperty: string },\n context: CollectionContext,\n): PropertyDerivationEntry {\n const effectiveFieldKey = buildEffectiveFieldKey(fieldKey, context);\n const errorLocation = `'${effectiveFieldKey}.${config.targetProperty}'`;\n\n // `targetProperty` is captured up front and `untypedConfig` is used inside\n // the source-narrowed blocks because TypeScript narrows `config` to `never`\n // once we negate a discriminant-required field (e.g. `!config.http` when\n // `source: 'http'` requires `http`).\n const untypedConfig = config as {\n source?: string;\n value?: unknown;\n expression?: unknown;\n functionName?: unknown;\n fn?: unknown;\n http?: unknown;\n responseExpression?: string;\n asyncFunctionName?: string;\n asyncFn?: unknown;\n dependsOn?: unknown;\n };\n\n // Reject configs that mix value-source fields with an explicit async source.\n if (untypedConfig.source === 'http' || untypedConfig.source === 'asyncFunction') {\n const conflictingSyncSources: string[] = [];\n if (untypedConfig.value !== undefined) conflictingSyncSources.push('value');\n if (untypedConfig.expression !== undefined) conflictingSyncSources.push('expression');\n if (untypedConfig.functionName !== undefined) conflictingSyncSources.push('functionName');\n if (untypedConfig.fn !== undefined) conflictingSyncSources.push('fn');\n if (conflictingSyncSources.length > 0) {\n throw new DynamicFormError(\n `Property derivation for ${errorLocation} sets 'source: ${JSON.stringify(untypedConfig.source)}' alongside ` +\n `sync value source(s) [${conflictingSyncSources.join(', ')}]. These are mutually exclusive — remove either ` +\n `the sync fields or the async source.`,\n );\n }\n }\n\n if (untypedConfig.source === 'http' && (untypedConfig.asyncFunctionName !== undefined || untypedConfig.asyncFn !== undefined)) {\n throw new DynamicFormError(\n `Property derivation for ${errorLocation} sets 'source: \"http\"' alongside an async function — these are mutually exclusive.`,\n );\n }\n if (untypedConfig.source === 'asyncFunction' && (untypedConfig.http !== undefined || untypedConfig.responseExpression !== undefined)) {\n throw new DynamicFormError(\n `Property derivation for ${errorLocation} sets 'source: \"asyncFunction\"' alongside HTTP fields — these are mutually exclusive.`,\n );\n }\n\n if (untypedConfig.source === 'http') {\n if (!untypedConfig.http) {\n throw new DynamicFormError(`HTTP property derivation for ${errorLocation} requires an 'http' config object.`);\n }\n if (typeof untypedConfig.responseExpression !== 'string' || untypedConfig.responseExpression.trim() === '') {\n throw new DynamicFormError(`HTTP property derivation for ${errorLocation} requires a non-empty 'responseExpression'.`);\n }\n if (!Array.isArray(untypedConfig.dependsOn) || untypedConfig.dependsOn.length === 0) {\n throw new DynamicFormError(\n `HTTP property derivation for ${errorLocation} requires explicit 'dependsOn'. ` +\n `Wildcard dependencies would trigger HTTP requests on every form change.`,\n );\n }\n if (untypedConfig.dependsOn.includes('*')) {\n throw new DynamicFormError(\n `HTTP property derivation for ${errorLocation} cannot use wildcard ('*') in 'dependsOn'. ` +\n `Wildcards would trigger HTTP requests on every form change. Specify explicit field dependencies instead.`,\n );\n }\n }\n\n if (untypedConfig.source === 'asyncFunction') {\n if (!untypedConfig.asyncFunctionName && !untypedConfig.asyncFn) {\n throw new DynamicFormError(`Async property derivation for ${errorLocation} requires either 'asyncFunctionName' or 'asyncFn'.`);\n }\n if (!Array.isArray(untypedConfig.dependsOn) || untypedConfig.dependsOn.length === 0) {\n throw new DynamicFormError(\n `Async property derivation for ${errorLocation} requires explicit 'dependsOn'. ` +\n `Wildcard dependencies would trigger async functions on every form change.`,\n );\n }\n if (untypedConfig.dependsOn.includes('*')) {\n throw new DynamicFormError(\n `Async property derivation for ${errorLocation} cannot use wildcard ('*') in 'dependsOn'. ` +\n `Wildcards would trigger async functions on every form change. Specify explicit field dependencies instead.`,\n );\n }\n }\n\n // Resolve `$self` / `$group` tokens against this entry's effective key so\n // property derivations track absolute paths the same way value derivations\n // do. The value-collector path has done this since #436; doing it here\n // closes the parity gap. `resolveTokenDependencies` throws if `$group` is\n // used on a root-level field (no parent container) — same contract as the\n // value path.\n const dependsOn = resolveTokenDependencies(extractDependenciesFromConfig(config), effectiveFieldKey, context);\n const condition = config.condition ?? true;\n const trigger = config.trigger ?? 'onChange';\n const debounceMs = trigger === 'debounced' ? (config as { debounceMs?: number }).debounceMs : undefined;\n\n return {\n fieldKey: effectiveFieldKey,\n targetProperty: config.targetProperty,\n dependsOn,\n condition,\n value: config.value,\n expression: config.expression,\n functionName: config.functionName,\n fn: extractInlineFn(config),\n http: config.http,\n responseExpression: config.responseExpression,\n asyncFunctionName: config.asyncFunctionName,\n asyncFn: extractInlineAsyncFn(config),\n trigger,\n debounceMs,\n debugName: config.debugName,\n originalConfig: config,\n };\n}\n","import { FieldDef } from '@ng-forge/dynamic-forms/internal';\nimport { collectAllDerivations } from '../derivation/derivation-collector';\nimport { PropertyDerivationCollection } from './property-derivation-types';\n\n/** Collects all property derivation entries from field definitions. */\nexport function collectPropertyDerivations(fields: FieldDef<unknown>[]): PropertyDerivationCollection {\n return collectAllDerivations(fields).property;\n}\n","import { DEV_MODE } from '../../utils/dev-mode';\nimport { CycleDetectionResult, DerivationCollection } from './derivation-types';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Node in the derivation dependency graph.\n *\n * @internal\n */\ninterface GraphNode {\n /** Field key this node represents */\n fieldKey: string;\n\n /** Set of field keys this node depends on (edges to other nodes) */\n dependsOn: Set<string>;\n\n /** Set of field keys that depend on this node (reverse edges) */\n dependedOnBy: Set<string>;\n}\n\n/**\n * State for topological sort cycle detection.\n *\n * @internal\n */\nconst enum VisitState {\n /** Node has not been visited */\n Unvisited = 0,\n /** Node is currently being processed (in the stack) */\n InProgress = 1,\n /** Node has been fully processed */\n Completed = 2,\n}\n\n/**\n * Detects cycles in the derivation dependency graph.\n *\n * @param collection - The collected derivation entries\n * @returns Result indicating whether a cycle exists and details if found\n */\nexport function detectCycles(collection: DerivationCollection): CycleDetectionResult {\n // Build directed graph from derivation entries\n const graph = buildDependencyGraph(collection);\n\n // No entries means no cycles\n if (graph.size === 0) {\n return { hasCycle: false };\n }\n\n // Build bidirectional pairs set for cycle exemption\n const bidirectionalPairs = detectBidirectionalPairs(collection);\n\n // Run DFS to detect cycles\n const result = detectCyclesWithDFS(graph, bidirectionalPairs);\n\n // Include bidirectional pairs in result for warnings\n if (bidirectionalPairs.size > 0) {\n result.bidirectionalPairs = Array.from(bidirectionalPairs);\n }\n\n return result;\n}\n\n/**\n * Detects bidirectional derivation pairs (A↔B patterns).\n *\n * @internal\n */\nfunction detectBidirectionalPairs(collection: DerivationCollection): Set<string> {\n const pairs = new Set<string>();\n\n // Build a map of fieldKey -> dependencies (other fields this derivation reads from)\n const dependencyMap = new Map<string, Set<string>>();\n for (const entry of collection.entries) {\n const deps = dependencyMap.get(entry.fieldKey) ?? new Set<string>();\n for (const dep of entry.dependsOn) {\n if (dep !== '*' && dep !== entry.fieldKey) {\n deps.add(dep);\n }\n }\n dependencyMap.set(entry.fieldKey, deps);\n }\n\n // Find bidirectional pairs: A depends on B AND B depends on A\n for (const [fieldA, depsA] of dependencyMap) {\n for (const fieldB of depsA) {\n const depsB = dependencyMap.get(fieldB);\n if (depsB?.has(fieldA)) {\n // Normalize pair key (alphabetically sorted)\n const pairKey = fieldA < fieldB ? `${fieldA}↔${fieldB}` : `${fieldB}↔${fieldA}`;\n pairs.add(pairKey);\n }\n }\n }\n\n return pairs;\n}\n\n/**\n * Checks if a cycle path represents a bidirectional pair.\n *\n * @internal\n */\nfunction isBidirectionalCycle(cyclePath: string[], bidirectionalPairs: Set<string>): boolean {\n // Bidirectional cycles are exactly: [A, B, A] (length 3 with first=last)\n if (cyclePath.length !== 3) {\n return false;\n }\n\n const [first, second, third] = cyclePath;\n if (first !== third) {\n return false;\n }\n\n // Check if this pair is in our bidirectional set\n const pairKey = first < second ? `${first}↔${second}` : `${second}↔${first}`;\n return bidirectionalPairs.has(pairKey);\n}\n\n/**\n * Builds a dependency graph from derivation entries.\n *\n * @internal\n */\nfunction buildDependencyGraph(collection: DerivationCollection): Map<string, GraphNode> {\n const graph = new Map<string, GraphNode>();\n\n // Helper to ensure a node exists\n const ensureNode = (fieldKey: string): GraphNode => {\n let node = graph.get(fieldKey);\n if (!node) {\n node = {\n fieldKey,\n dependsOn: new Set(),\n dependedOnBy: new Set(),\n };\n graph.set(fieldKey, node);\n }\n return node;\n };\n\n for (const entry of collection.entries) {\n const fieldNode = ensureNode(entry.fieldKey);\n\n // Track dependencies from the derivation expression/condition.\n // For cycle detection, edges go from dependency -> field:\n // If field A depends on field B, then when B changes, A gets updated.\n // A cycle exists when there's a circular chain of such dependencies.\n for (const dep of entry.dependsOn) {\n // Skip wildcard dependencies and self-references\n if (dep !== '*' && dep !== entry.fieldKey) {\n const depNode = ensureNode(dep);\n // The field depends on 'dep'\n fieldNode.dependsOn.add(dep);\n // 'dep' is depended on by the field\n depNode.dependedOnBy.add(entry.fieldKey);\n }\n }\n }\n\n return graph;\n}\n\n/**\n * Detects cycles using depth-first search.\n *\n * @internal\n */\nfunction detectCyclesWithDFS(graph: Map<string, GraphNode>, bidirectionalPairs: Set<string>): CycleDetectionResult {\n const visitState = new Map<string, VisitState>();\n const parent = new Map<string, string | null>();\n\n // Initialize all nodes as unvisited\n for (const fieldKey of graph.keys()) {\n visitState.set(fieldKey, VisitState.Unvisited);\n }\n\n // Process each unvisited node\n for (const fieldKey of graph.keys()) {\n if (visitState.get(fieldKey) === VisitState.Unvisited) {\n const cycleResult = dfsVisit(fieldKey, graph, visitState, parent, [], bidirectionalPairs);\n if (cycleResult) {\n return cycleResult;\n }\n }\n }\n\n return { hasCycle: false };\n}\n\n/**\n * DFS visit function that detects back edges (cycles).\n *\n * @internal\n */\nfunction dfsVisit(\n fieldKey: string,\n graph: Map<string, GraphNode>,\n visitState: Map<string, VisitState>,\n parent: Map<string, string | null>,\n path: string[],\n bidirectionalPairs: Set<string>,\n): CycleDetectionResult | null {\n visitState.set(fieldKey, VisitState.InProgress);\n path.push(fieldKey);\n\n const node = graph.get(fieldKey);\n if (!node) {\n visitState.set(fieldKey, VisitState.Completed);\n path.pop();\n return null;\n }\n\n // Check all nodes that this field's changes would trigger\n for (const targetFieldKey of node.dependedOnBy) {\n const targetState = visitState.get(targetFieldKey);\n\n if (targetState === VisitState.InProgress) {\n // Back edge found - potential cycle\n const cyclePath = extractCyclePath(path, targetFieldKey);\n\n // Allow bidirectional sync patterns (A→B→A)\n // These stabilize via equality checks at runtime\n if (isBidirectionalCycle(cyclePath, bidirectionalPairs)) {\n // Continue DFS without reporting this as a cycle\n continue;\n }\n\n return {\n hasCycle: true,\n cyclePath,\n errorMessage: formatCycleError(cyclePath),\n };\n }\n\n if (targetState === VisitState.Unvisited) {\n parent.set(targetFieldKey, fieldKey);\n const result = dfsVisit(targetFieldKey, graph, visitState, parent, path, bidirectionalPairs);\n if (result) {\n return result;\n }\n }\n }\n\n visitState.set(fieldKey, VisitState.Completed);\n path.pop();\n return null;\n}\n\n/**\n * Extracts the cycle path from the DFS path.\n *\n * @internal\n */\nfunction extractCyclePath(path: string[], cycleStart: string): string[] {\n const cycleStartIndex = path.indexOf(cycleStart);\n if (cycleStartIndex === -1) {\n // Shouldn't happen, but handle gracefully\n return [...path, cycleStart];\n }\n\n // Extract from cycle start to current position, then back to start\n const cyclePath = path.slice(cycleStartIndex);\n cyclePath.push(cycleStart); // Close the cycle\n return cyclePath;\n}\n\n/**\n * Formats a human-readable error message for a detected cycle.\n *\n * @internal\n */\nfunction formatCycleError(cyclePath: string[]): string {\n const pathStr = cyclePath.join(' -> ');\n return (\n `Derivation cycle detected: ${pathStr}\\n` +\n `This would cause an infinite loop at runtime. ` +\n `Remove one of the derivations to break the cycle.`\n );\n}\n\n/**\n * Validates a derivation collection and throws if cycles are detected.\n *\n * @param collection - The collected derivation entries to validate\n * @param logger - Optional logger for dev-mode warnings\n * @throws Error if a cycle is detected, with details about the cycle\n */\nexport function validateNoCycles(collection: DerivationCollection, logger?: Logger): void {\n const result = detectCycles(collection);\n\n if (result.hasCycle) {\n throw new Error(result.errorMessage);\n }\n\n // Warn about bidirectional patterns in dev mode\n if (DEV_MODE && result.bidirectionalPairs && result.bidirectionalPairs.length > 0 && logger) {\n logger.warn(\n 'Derivation: bidirectional derivation patterns detected. ' +\n 'These patterns stabilize via equality checks, but may oscillate with floating-point values ' +\n '(e.g., currency conversions with rounding). ' +\n 'Consider adding tolerance-based comparisons for numeric values.',\n result.bidirectionalPairs,\n );\n }\n}\n","import { HttpClient } from '@angular/common/http';\nimport { Signal, untracked } from '@angular/core';\nimport type { FieldTree } from '@angular/forms/signals';\nimport { EMPTY, Observable, debounceTime, filter, map, pairwise, startWith, switchMap } from 'rxjs';\nimport { catchError } from 'rxjs/operators';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport { getChangedKeys, isEqual } from '@ng-forge/dynamic-forms/internal';\nimport { isArrayPlaceholderPath } from '../../utils/path-utils/path-utils';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport { evaluateCondition } from '@ng-forge/dynamic-forms/internal';\nimport { ExpressionParser } from '@ng-forge/dynamic-forms/internal';\nimport { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { resolveHttpRequest } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { DerivationEntry } from './derivation-types';\nimport { DerivationLogger } from './derivation-logger.service';\nimport type { WarningTracker } from '@ng-forge/dynamic-forms/internal';\nimport { readFieldDirty, resetFieldState, applyValueToForm } from './field-value-utils';\nimport { readFieldStateInfo, createFormFieldStateMap } from '@ng-forge/dynamic-forms/internal';\nimport type { FieldTreeRecord } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Context required for creating HTTP derivation streams.\n *\n * @internal\n */\nexport interface HttpDerivationStreamContext {\n /** Signal containing the current form value */\n formValue: Signal<Record<string, unknown>>;\n\n /** Signal containing the form accessor */\n form: Signal<FieldTree<unknown>>;\n\n /** Angular HttpClient for making requests */\n httpClient: HttpClient;\n\n /** Logger for diagnostic output */\n logger: Logger;\n\n /** Signal containing the derivation logger */\n derivationLogger: Signal<DerivationLogger>;\n\n /** Lazy resolver for custom functions — called per-emission to get fresh values */\n customFunctions?: () => Record<string, CustomFunction> | undefined;\n\n /** Lazy resolver for external data — called per-emission to get fresh values */\n externalData?: () => Record<string, unknown> | undefined;\n\n /** Warning tracker to suppress duplicate missing-field warnings */\n warningTracker?: WarningTracker;\n}\n\nconst LOG_PREFIX = 'HTTP Derivation -';\nconst DEFAULT_HTTP_DEBOUNCE_MS = 300;\n\n/**\n * Creates an RxJS Observable stream that processes an HTTP derivation entry.\n *\n * @param entry - The derivation entry with HTTP configuration\n * @param formValue$ - Observable of form value changes\n * @param context - Context with HttpClient, logger, etc.\n * @returns Observable that applies HTTP-derived values to the form\n * @internal\n */\nexport function createHttpDerivationStream(\n entry: DerivationEntry,\n formValue$: Observable<Record<string, unknown>>,\n context: HttpDerivationStreamContext,\n): Observable<void> {\n // Guard: no array support for HTTP derivations\n if (isArrayPlaceholderPath(entry.fieldKey)) {\n context.logger.warn(\n `${LOG_PREFIX} HTTP derivation for array field '${entry.fieldKey}' is not supported. ` +\n `Array HTTP derivations will be supported in a future release.`,\n );\n return EMPTY;\n }\n\n if (!entry.http || !entry.responseExpression) {\n return EMPTY;\n }\n\n // Capture after the guard — TS narrows this to `string` here\n const responseExpression = entry.responseExpression;\n const debounceMs = entry.debounceMs ?? DEFAULT_HTTP_DEBOUNCE_MS;\n\n return formValue$.pipe(\n startWith(null as Record<string, unknown> | null),\n pairwise(),\n map(([previous, current]) => ({\n current: current!,\n changedFields: getChangedKeys(previous, current!),\n })),\n // Only proceed when a dependency in entry.dependsOn changed\n filter(({ changedFields }) => {\n if (changedFields.size === 0) return false;\n return entry.dependsOn.some((dep) => changedFields.has(dep));\n }),\n debounceTime(debounceMs),\n switchMap(({ current, changedFields }) => {\n return new Observable<void>((subscriber) => {\n const formAccessor = untracked(() => context.form());\n\n // Check stopOnUserOverride — skip if the user has manually edited the target field\n if (entry.stopOnUserOverride) {\n const isDirty = readFieldDirty(formAccessor, entry.fieldKey);\n if (isDirty) {\n // Re-engage: if a dependency changed, clear dirty state so derivation resumes\n if (entry.reEngageOnDependencyChange && changedFields.size > 0) {\n if (entry.dependsOn.some((dep) => changedFields.has(dep))) {\n resetFieldState(formAccessor, entry.fieldKey);\n // Fall through — proceed with the HTTP request\n } else {\n // Dependency didn't change, still skip\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'user-override',\n });\n subscriber.complete();\n return;\n }\n } else {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'user-override',\n });\n subscriber.complete();\n return;\n }\n }\n }\n\n // Resolve lazy context values per-emission\n const customFunctions = context.customFunctions?.();\n const externalData = context.externalData?.();\n\n // Build evaluation context for condition check and request resolution\n const fieldValue = getNestedValue(current, entry.fieldKey);\n const fieldAccessor = (formAccessor as FieldTreeRecord)[entry.fieldKey];\n const fieldState = fieldAccessor ? readFieldStateInfo(fieldAccessor, false) : undefined;\n\n const evalContext: EvaluationContext = {\n fieldValue,\n formValue: current,\n fieldPath: entry.fieldKey,\n customFunctions,\n externalData,\n logger: context.logger,\n fieldState,\n formFieldState: createFormFieldStateMap(formAccessor, false),\n };\n\n // Evaluate condition — skip if false\n if (entry.condition !== true) {\n const conditionMet = typeof entry.condition === 'boolean' ? entry.condition : evaluateCondition(entry.condition, evalContext);\n if (!conditionMet) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'condition-false',\n });\n subscriber.complete();\n return;\n }\n }\n\n // Returns null when a path param is undefined — suppress the request\n const resolvedRequest = resolveHttpRequest(entry.http!, evalContext);\n\n if (!resolvedRequest) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'condition-false',\n });\n subscriber.complete();\n return;\n }\n\n // Cancellation is handled declaratively by the orchestrator's outer\n // switchMap: when the entry set changes, the outer stream unsubscribes\n // from this inner observable, the cleanup callback below runs, and\n // the in-flight HTTP request is cancelled.\n const method = (resolvedRequest.method ?? 'GET').toUpperCase();\n const httpSub = context.httpClient\n .request(method, resolvedRequest.url, {\n body: resolvedRequest.body,\n headers: resolvedRequest.headers as Record<string, string>,\n })\n .subscribe({\n next: (response) => {\n try {\n // Extract value from response using responseExpression\n const newValue = ExpressionParser.evaluate(responseExpression, { response });\n\n // Compare with current value — skip if unchanged\n const currentFormValue = untracked(() => context.formValue());\n const currentValue = getNestedValue(currentFormValue, entry.fieldKey);\n\n if (isEqual(currentValue, newValue)) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'value-unchanged',\n });\n subscriber.complete();\n return;\n }\n\n // Re-read dirty state at apply-time: the user may have edited the field\n // after the request started but before it resolved. Mirrors the\n // fire-time stopOnUserOverride guard in the switchMap above.\n const currentForm = untracked(() => context.form());\n if (entry.stopOnUserOverride && readFieldDirty(currentForm, entry.fieldKey)) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'user-override',\n });\n subscriber.complete();\n return;\n }\n\n // Apply the value to the form\n applyValueToForm(entry.fieldKey, newValue, currentForm, context.logger, context.warningTracker);\n\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'applied',\n previousValue: currentValue,\n newValue,\n });\n subscriber.complete();\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Failed to process response for '${entry.fieldKey}': ${message}`);\n subscriber.complete();\n }\n },\n error: (error) => {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} HTTP request failed for '${entry.fieldKey}': ${message}`);\n subscriber.complete();\n },\n });\n\n // Cleanup: RxJS unsubscription cancels the in-flight HTTP request.\n // Angular's HttpClient uses an internal AbortController when operating with\n // the fetch backend, so unsubscribing is sufficient for network cancellation.\n return () => {\n httpSub.unsubscribe();\n };\n });\n }),\n catchError((error) => {\n // Safety net — should not normally be reached since inner errors are caught\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Unexpected stream error for '${entry.fieldKey}': ${message}`);\n return EMPTY;\n }),\n );\n}\n","import { Signal, untracked } from '@angular/core';\nimport type { FieldTree } from '@angular/forms/signals';\nimport { EMPTY, from, Observable, debounceTime, filter, map, pairwise, startWith, switchMap } from 'rxjs';\nimport { catchError } from 'rxjs/operators';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport { getChangedKeys, isEqual } from '@ng-forge/dynamic-forms/internal';\nimport { isArrayPlaceholderPath } from '../../utils/path-utils/path-utils';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport type { AsyncDerivationFunction } from '@ng-forge/dynamic-forms/internal';\nimport { evaluateCondition } from '@ng-forge/dynamic-forms/internal';\nimport { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { DerivationEntry } from './derivation-types';\nimport { DerivationLogger } from './derivation-logger.service';\nimport type { WarningTracker } from '@ng-forge/dynamic-forms/internal';\nimport { readFieldDirty, resetFieldState, applyValueToForm } from './field-value-utils';\nimport { readFieldStateInfo, createFormFieldStateMap } from '@ng-forge/dynamic-forms/internal';\nimport type { FieldTreeRecord } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Context required for creating async derivation streams.\n *\n * @internal\n */\nexport interface AsyncDerivationStreamContext {\n /** Signal containing the current form value */\n formValue: Signal<Record<string, unknown>>;\n\n /** Signal containing the form accessor */\n form: Signal<FieldTree<unknown>>;\n\n /** Logger for diagnostic output */\n logger: Logger;\n\n /** Signal containing the derivation logger */\n derivationLogger: Signal<DerivationLogger>;\n\n /** Lazy resolver for custom functions — called per-emission to get fresh values */\n customFunctions?: () => Record<string, CustomFunction> | undefined;\n\n /** Lazy resolver for async derivation functions — called per-emission to get fresh values */\n asyncDerivationFunctions?: () => Record<string, AsyncDerivationFunction> | undefined;\n\n /** Lazy resolver for external data — called per-emission to get fresh values */\n externalData?: () => Record<string, unknown> | undefined;\n\n /** Warning tracker to suppress duplicate missing-field warnings */\n warningTracker?: WarningTracker;\n}\n\nconst LOG_PREFIX = 'Async Derivation -';\nconst DEFAULT_ASYNC_DEBOUNCE_MS = 300;\n\n/**\n * Creates an RxJS Observable stream that processes an async derivation entry.\n *\n * @param entry - The derivation entry with asyncFunctionName\n * @param formValue$ - Observable of form value changes\n * @param context - Context with logger, etc.\n * @returns Observable that applies async-derived values to the form\n * @internal\n */\nexport function createAsyncDerivationStream(\n entry: DerivationEntry,\n formValue$: Observable<Record<string, unknown>>,\n context: AsyncDerivationStreamContext,\n): Observable<void> {\n // Guard: no array support for async derivations\n if (isArrayPlaceholderPath(entry.fieldKey)) {\n context.logger.warn(\n `${LOG_PREFIX} Async derivation for array field '${entry.fieldKey}' is not supported. ` +\n `Array async derivations will be supported in a future release.`,\n );\n return EMPTY;\n }\n\n if (!entry.asyncFunctionName && !entry.asyncFn) {\n context.logger.warn(\n `${LOG_PREFIX} Derivation for '${entry.fieldKey}' has neither \"asyncFunctionName\" nor \"asyncFn\". ` +\n `One of the two is required for source: 'asyncFunction'.`,\n );\n return EMPTY;\n }\n\n if (entry.asyncFn && entry.asyncFunctionName) {\n context.logger.warn(\n `${LOG_PREFIX} Both \"asyncFn\" and \"asyncFunctionName\" are set on derivation for '${entry.fieldKey}'. ` +\n `Inline \"asyncFn\" takes precedence; \"asyncFunctionName\" is ignored.`,\n );\n }\n\n const inlineAsyncFn = entry.asyncFn;\n const asyncFunctionName = entry.asyncFunctionName;\n const debounceMs = entry.debounceMs ?? DEFAULT_ASYNC_DEBOUNCE_MS;\n\n // startWith(null) → pairwise() causes all fields to appear \"changed\" on the\n // first emission (null vs actual form value), so every dependsOn field matches.\n // This intentionally fires the async derivation on initial form load — same\n // pattern as http-derivation-stream.ts.\n return formValue$.pipe(\n startWith(null as Record<string, unknown> | null),\n pairwise(),\n map(([previous, current]) => ({\n current: current!,\n changedFields: getChangedKeys(previous, current!),\n })),\n // Only proceed when a dependency in entry.dependsOn changed\n filter(({ changedFields }) => {\n if (changedFields.size === 0) return false;\n return entry.dependsOn.some((dep) => changedFields.has(dep));\n }),\n debounceTime(debounceMs),\n switchMap(({ current, changedFields }) => {\n return new Observable<void>((subscriber) => {\n const formAccessor = untracked(() => context.form());\n\n // Check stopOnUserOverride — skip if the user has manually edited the target field\n if (entry.stopOnUserOverride) {\n const isDirty = readFieldDirty(formAccessor, entry.fieldKey);\n if (isDirty) {\n // Re-engage: the outer filter already guarantees changedFields is non-empty\n // and that a dependsOn field changed, so only check reEngageOnDependencyChange.\n if (entry.reEngageOnDependencyChange) {\n resetFieldState(formAccessor, entry.fieldKey);\n // Fall through — proceed with the async call\n } else {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'user-override',\n });\n subscriber.complete();\n return;\n }\n }\n }\n\n // Resolve lazy context values per-emission\n const customFunctions = context.customFunctions?.();\n const asyncDerivationFunctions = context.asyncDerivationFunctions?.();\n const externalData = context.externalData?.();\n\n // Inline `asyncFn` wins; otherwise look up the registered function.\n const asyncFn = inlineAsyncFn ?? (asyncFunctionName ? asyncDerivationFunctions?.[asyncFunctionName] : undefined);\n if (!asyncFn) {\n context.logger.warn(\n `${LOG_PREFIX} Async derivation function '${asyncFunctionName}' not found for field '${entry.fieldKey}'. ` +\n `Register it in customFnConfig.asyncDerivations.`,\n );\n subscriber.complete();\n return;\n }\n\n // Build evaluation context for condition check\n const fieldValue = getNestedValue(current, entry.fieldKey);\n const fieldAccessor = (formAccessor as FieldTreeRecord)[entry.fieldKey];\n const fieldState = fieldAccessor ? readFieldStateInfo(fieldAccessor, false) : undefined;\n\n const evalContext: EvaluationContext = {\n fieldValue,\n formValue: current,\n fieldPath: entry.fieldKey,\n customFunctions,\n externalData,\n logger: context.logger,\n fieldState,\n formFieldState: createFormFieldStateMap(formAccessor, false),\n };\n\n // Evaluate condition — skip if false\n if (entry.condition !== true) {\n const conditionMet = typeof entry.condition === 'boolean' ? entry.condition : evaluateCondition(entry.condition, evalContext);\n if (!conditionMet) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'condition-false',\n });\n subscriber.complete();\n return;\n }\n }\n\n // Call the async function (normalize Promise → Observable)\n const result$ = from(asyncFn(evalContext));\n\n const asyncSub = result$.subscribe({\n next: (newValue) => {\n try {\n // Compare with current value — skip if unchanged\n const currentFormValue = untracked(() => context.formValue());\n const currentValue = getNestedValue(currentFormValue, entry.fieldKey);\n\n if (isEqual(currentValue, newValue)) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'value-unchanged',\n });\n subscriber.complete();\n return;\n }\n\n // Re-read dirty state at apply-time: the user may have edited the field\n // after the async call started but before it resolved. Mirrors the\n // fire-time stopOnUserOverride guard in the switchMap above.\n const currentForm = untracked(() => context.form());\n if (entry.stopOnUserOverride && readFieldDirty(currentForm, entry.fieldKey)) {\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'skipped',\n skipReason: 'user-override',\n });\n subscriber.complete();\n return;\n }\n\n // Apply the value to the form\n applyValueToForm(entry.fieldKey, newValue, currentForm, context.logger, context.warningTracker);\n\n const derivationLogger = untracked(() => context.derivationLogger());\n derivationLogger.evaluation({\n debugName: entry.debugName,\n fieldKey: entry.fieldKey,\n result: 'applied',\n previousValue: currentValue,\n newValue,\n });\n subscriber.complete();\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Failed to process result for '${entry.fieldKey}': ${message}`);\n subscriber.complete();\n }\n },\n error: (error) => {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Async function failed for '${entry.fieldKey}': ${message}`);\n subscriber.complete();\n },\n complete: () => {\n // Handles Observables that complete without emitting (e.g. EMPTY),\n // preventing the inner Observable from staying open indefinitely.\n subscriber.complete();\n },\n });\n\n // Cleanup subscription on unsubscribe (switchMap cancellation)\n return () => {\n asyncSub.unsubscribe();\n };\n });\n }),\n catchError((error) => {\n // Safety net — should not normally be reached since inner errors are caught\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Unexpected stream error for '${entry.fieldKey}': ${message}`);\n return EMPTY;\n }),\n );\n}\n","import { isSignal, Signal, untracked } from '@angular/core';\n\n/**\n * Resolves a record of external-data signals to their current values without\n * establishing reactive dependencies.\n *\n * @internal\n */\nexport function resolveExternalData(\n externalDataSignal: Signal<Record<string, Signal<unknown>> | undefined> | undefined,\n): Record<string, unknown> | undefined {\n const externalDataRecord = untracked(() => externalDataSignal?.());\n\n if (!externalDataRecord) return undefined;\n\n const resolved: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(externalDataRecord)) {\n if (isSignal(value)) {\n resolved[key] = untracked(() => value());\n } else {\n resolved[key] = value;\n }\n }\n\n return resolved;\n}\n","import { DEFAULT_DEBOUNCE_MS } from '../../utils/debounce/debounce';\nimport { BaseDerivationEntry } from './derivation-entry-base';\n\n/**\n * Returns the unique set of debounce periods declared by `entries` whose\n * `trigger === 'debounced'`. Entries without an explicit `debounceMs` use\n * {@link DEFAULT_DEBOUNCE_MS}.\n *\n * @internal\n */\nexport function getDebouncePeriods(entries: ReadonlyArray<Pick<BaseDerivationEntry, 'trigger' | 'debounceMs'>>): number[] {\n const periods = new Set<number>();\n for (const entry of entries) {\n if (entry.trigger === 'debounced') {\n periods.add(entry.debounceMs ?? DEFAULT_DEBOUNCE_MS);\n }\n }\n return Array.from(periods);\n}\n\n/**\n * Filters `entries` down to those whose `trigger === 'debounced'` and whose\n * effective `debounceMs` (defaulting to {@link DEFAULT_DEBOUNCE_MS}) equals `period`.\n *\n * @internal\n */\nexport function filterEntriesByDebouncePeriod<TEntry extends Pick<BaseDerivationEntry, 'trigger' | 'debounceMs'>>(\n entries: ReadonlyArray<TEntry>,\n period: number,\n): TEntry[] {\n return entries.filter((entry) => entry.trigger === 'debounced' && (entry.debounceMs ?? DEFAULT_DEBOUNCE_MS) === period);\n}\n","import { EMPTY, Observable, distinctUntilChanged, map, switchMap, merge } from 'rxjs';\n\n/**\n * Compares two entry lists as multisets of content signatures. Order-insensitive\n * — entries reordered with the same content compare equal, so topological-sort\n * reorderings triggered by unrelated entries don't churn downstream stream\n * lifecycles.\n *\n * @internal\n */\nexport function entrySetsEqual<T>(prev: T[], next: T[], sig: (item: T) => string): boolean {\n if (prev.length !== next.length) return false;\n\n const counts = new Map<string, number>();\n for (const item of prev) {\n const key = sig(item);\n counts.set(key, (counts.get(key) ?? 0) + 1);\n }\n\n for (const item of next) {\n const key = sig(item);\n const remaining = counts.get(key);\n if (!remaining) return false;\n if (remaining === 1) counts.delete(key);\n else counts.set(key, remaining - 1);\n }\n\n return counts.size === 0;\n}\n\n/**\n * Configuration for {@link buildEntryStreamPipeline}.\n *\n * @internal\n */\nexport interface EntryStreamPipelineConfig<TCollection, TEntry> {\n /** Source of collection updates (typically a `toObservable(...computed)`). */\n collection$: Observable<TCollection | null>;\n\n /** Extracts the subset of entries this pipeline cares about. */\n selectEntries: (collection: TCollection | null) => TEntry[];\n\n /** Content-addressed identity for each entry; consumed by {@link entrySetsEqual}. */\n entrySignature: (entry: TEntry) => string;\n\n /**\n * Builds an Observable for an entry. The returned stream's lifecycle is\n * owned by the outer pipeline — when the entry set changes, the previous\n * merged inner stream is unsubscribed (cancelling in-flight work) and a\n * fresh batch is created from the new entries.\n */\n createStream: (entry: TEntry) => Observable<unknown> | null;\n}\n\n/**\n * Declarative pipeline shared by both derivation orchestrators:\n *\n * @internal\n */\nexport function buildEntryStreamPipeline<TCollection, TEntry>(config: EntryStreamPipelineConfig<TCollection, TEntry>): Observable<unknown> {\n return config.collection$.pipe(\n map((collection) => config.selectEntries(collection)),\n distinctUntilChanged((prev, next) => entrySetsEqual(prev, next, config.entrySignature)),\n switchMap((entries) => {\n if (entries.length === 0) return EMPTY;\n const streams = entries.map((entry) => config.createStream(entry)).filter((stream): stream is Observable<unknown> => stream !== null);\n if (streams.length === 0) return EMPTY;\n return merge(...streams);\n }),\n );\n}\n","import { DestroyRef, Injector, Signal, untracked } from '@angular/core';\nimport { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';\nimport {\n auditTime,\n catchError,\n combineLatestWith,\n debounceTime,\n EMPTY,\n exhaustMap,\n filter,\n map,\n merge,\n of,\n Observable,\n pairwise,\n queueScheduler,\n scheduled,\n startWith,\n switchMap,\n timer,\n} from 'rxjs';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { DEFAULT_DEBOUNCE_MS } from '../../utils/debounce/debounce';\nimport { getChangedKeys } from '@ng-forge/dynamic-forms/internal';\nimport { getDebouncePeriods } from './debounce-period-utils';\nimport { BaseDerivationEntry } from './derivation-entry-base';\nimport { buildEntryStreamPipeline } from './entry-set-utils';\n\n/**\n * Minimum shape a derivation collection must expose for the reactive setup\n * helpers to work with both value-derivation and property-derivation pipelines.\n * Both pipelines' collections satisfy this — they each carry an `entries`\n * array of {@link BaseDerivationEntry}-conforming records.\n *\n * @internal\n */\nexport interface ReactiveDerivationCollection {\n entries: ReadonlyArray<Pick<BaseDerivationEntry, 'trigger' | 'debounceMs'>>;\n}\n\n/**\n * Options shared by both `setupOnChangeStream` and `setupDebouncedStream`.\n * Centralizes the boilerplate of subscribing through `toObservable` + the\n * orchestrator's destroyRef + the canonical error-logging label so neither\n * orchestrator has to duplicate it.\n *\n * @internal\n */\ninterface BaseReactiveStreamOptions<TCollection extends ReactiveDerivationCollection> {\n /** Injector for `toObservable` calls. Must be the orchestrator's `inject(Injector)`. */\n injector: Injector;\n /** DestroyRef tied to the orchestrator instance — auto-cleans the subscription on form teardown. */\n destroyRef: DestroyRef;\n /** Logger used for stream-level errors (synchronous throws inside operators). */\n logger: Logger;\n /** Human-readable label inserted into the error log, e.g. 'Derivation onChange stream error'. */\n errorLabel: string;\n /** Signal exposing the orchestrator's collected derivations (or null when there are none). */\n collectionSignal: Signal<TCollection | null>;\n /** Signal exposing the live form value. */\n formValueSignal: Signal<Record<string, unknown>>;\n}\n\n/**\n * Options for the **onChange** reactive stream.\n *\n * @internal\n */\nexport interface OnChangeStreamOptions<TCollection extends ReactiveDerivationCollection> extends BaseReactiveStreamOptions<TCollection> {\n applyOnChange: (collection: TCollection, changedFields: Set<string>) => void;\n additionalAwakeners?: Signal<unknown>[];\n}\n\n/**\n * Subscribe to the onChange reactive stream and route emissions through\n * `applyOnChange`. The pipeline:\n *\n * @internal\n */\nexport function setupOnChangeStream<TCollection extends ReactiveDerivationCollection>(opts: OnChangeStreamOptions<TCollection>): void {\n const collection$ = toObservable(opts.collectionSignal, { injector: opts.injector });\n const formValue$ = toObservable(opts.formValueSignal, { injector: opts.injector });\n const extra$ = opts.additionalAwakeners?.length ? opts.additionalAwakeners.map((s) => toObservable(s, { injector: opts.injector })) : [];\n\n collection$\n .pipe(\n filter((collection): collection is TCollection => collection !== null),\n combineLatestWith(formValue$, ...extra$),\n auditTime(0),\n startWith(null as readonly unknown[] | null),\n pairwise(),\n filter((pair): pair is [readonly unknown[] | null, readonly unknown[]] => pair[1] !== null),\n map(([previous, current]) => ({\n collection: current[0] as TCollection,\n changedFields: getChangedKeys(\n (previous?.[1] as Record<string, unknown> | undefined) ?? null,\n current[1] as Record<string, unknown>,\n ),\n })),\n exhaustMap(({ collection, changedFields }) => {\n opts.applyOnChange(collection, changedFields);\n return scheduled([null], queueScheduler);\n }),\n takeUntilDestroyed(opts.destroyRef),\n )\n .subscribe({\n error: (err) => opts.logger.error(opts.errorLabel, err),\n });\n}\n\n/**\n * Options for the **debounced** reactive stream.\n *\n * @internal\n */\nexport interface DebouncedStreamOptions<TCollection extends ReactiveDerivationCollection> extends BaseReactiveStreamOptions<TCollection> {\n applyDebouncedForPeriod: (debounceMs: number, collection: TCollection, changedFields: Set<string>) => void;\n}\n\n/**\n * Subscribe to the debounced reactive stream and route emissions through\n * `applyDebouncedForPeriod`. See {@link DebouncedStreamOptions} for stream\n * shape; uses `switchMap` (not `exhaustMap`) so newer changes cancel\n * older still-debouncing work — we want only the latest debounced value\n * applied per period.\n *\n * @internal\n */\nexport function setupDebouncedStream<TCollection extends ReactiveDerivationCollection>(opts: DebouncedStreamOptions<TCollection>): void {\n toObservable(opts.formValueSignal, { injector: opts.injector })\n .pipe(\n debounceTime(DEFAULT_DEBOUNCE_MS),\n startWith(null as Record<string, unknown> | null),\n pairwise(),\n filter((pair): pair is [Record<string, unknown> | null, Record<string, unknown>] => pair[1] !== null),\n map(([previous, current]) => ({ current, changedFields: getChangedKeys(previous, current) })),\n filter(({ changedFields }) => changedFields.size > 0),\n switchMap(({ changedFields }) => {\n const collection = untracked(() => opts.collectionSignal());\n if (!collection) return of(null);\n\n const debouncePeriods = getDebouncePeriods(collection.entries);\n if (debouncePeriods.length === 0) return of(null);\n\n const periodStreams: Observable<unknown>[] = debouncePeriods.map((debounceMs) => {\n const additionalWait = Math.max(0, debounceMs - DEFAULT_DEBOUNCE_MS);\n if (additionalWait === 0) {\n return of(null).pipe(\n map(() => {\n opts.applyDebouncedForPeriod(debounceMs, collection, changedFields);\n }),\n );\n }\n return timer(additionalWait).pipe(\n map(() => {\n // Re-read collection because additional wait may have elapsed\n const fresh = untracked(() => opts.collectionSignal()) ?? collection;\n opts.applyDebouncedForPeriod(debounceMs, fresh, changedFields);\n }),\n );\n });\n\n return merge(...periodStreams);\n }),\n takeUntilDestroyed(opts.destroyRef),\n )\n .subscribe({\n error: (err) => opts.logger.error(opts.errorLabel, err),\n });\n}\n\n/**\n * Options for setting up an entry-keyed async stream (HTTP derivations or\n * registered async-function derivations). Both orchestrators previously\n * hand-rolled this pattern: subscribe to the collection signal via\n * {@link buildEntryStreamPipeline}, filter to entries of the relevant kind,\n * key each emission by a stable signature so unrelated topological reorderings\n * don't churn subscriptions, and per-entry call the caller's `createStream`.\n *\n * @internal\n */\nexport interface EntryAsyncStreamOptions<TCollection extends ReactiveDerivationCollection, TEntry> {\n injector: Injector;\n destroyRef: DestroyRef;\n logger: Logger;\n /** Human-readable label included in outer-pipeline error logs. */\n errorLabel: string;\n collectionSignal: Signal<TCollection | null>;\n /** Return only the entries this stream cares about (HTTP-bearing, async-bearing, …). */\n selectEntries: (collection: TCollection | null) => TEntry[];\n /** Stable identity for an entry. Used by `entrySetsEqual` to skip rebuild when the set is unchanged. */\n entrySignature: (entry: TEntry) => string;\n /** Build the inner per-entry stream. Returning `null` suppresses the entry (e.g., HttpClient missing). */\n createStream: (entry: TEntry) => Observable<unknown> | null;\n}\n\n/**\n * Subscribe to an entry-keyed async stream. Both HTTP and async-function\n * variants of both pipelines share this exact wrapping; only the\n * caller-supplied callbacks change.\n *\n * @internal\n */\nexport function setupEntryAsyncStream<TCollection extends ReactiveDerivationCollection, TEntry>(\n opts: EntryAsyncStreamOptions<TCollection, TEntry>,\n): void {\n const collection$ = toObservable(opts.collectionSignal, { injector: opts.injector });\n\n buildEntryStreamPipeline<TCollection, TEntry>({\n collection$,\n selectEntries: opts.selectEntries,\n entrySignature: opts.entrySignature,\n createStream: opts.createStream,\n })\n .pipe(\n catchError((err) => {\n opts.logger.error(opts.errorLabel, err);\n return EMPTY;\n }),\n takeUntilDestroyed(opts.destroyRef),\n )\n .subscribe();\n}\n","import { DerivationEntry } from '../derivation/derivation-types';\n\n/** Entry representing a collected property derivation from field definitions. */\nexport type PropertyDerivationEntry = DerivationEntry & { targetProperty: string };\n\n/** Collection of all property derivation entries from a form's field definitions. */\nexport interface PropertyDerivationCollection {\n entries: PropertyDerivationEntry[];\n}\n\n/**\n * Creates an empty property derivation collection.\n *\n * @returns Empty collection\n * @internal\n */\nexport function createEmptyPropertyDerivationCollection(): PropertyDerivationCollection {\n return { entries: [] };\n}\n\n/**\n * Returns true when the entry's value source is async (HTTP or async function)\n * and therefore must be processed by a dedicated stream subscription rather\n * than the synchronous applicator pipeline.\n *\n * @internal\n */\nexport function isAsyncPropertyDerivationEntry(entry: PropertyDerivationEntry): boolean {\n return Boolean(entry.http) || Boolean(entry.asyncFunctionName) || Boolean(entry.asyncFn);\n}\n","import { Signal, untracked } from '@angular/core';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport { ConditionalExpression } from '@ng-forge/dynamic-forms/internal';\n\nimport { parseArrayPath, resolveArrayPath, isArrayPlaceholderPath } from '../../utils/path-utils/path-utils';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport { evaluateCondition } from '@ng-forge/dynamic-forms/internal';\nimport { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport type { WarningTracker } from '@ng-forge/dynamic-forms/internal';\nimport { computeValueFromEntry } from '../derivation/compute-derived-value';\nimport { getParentPathInScope, resolveArrayItemScope } from '../derivation/evaluation-scope';\nimport { isAsyncPropertyDerivationEntry, PropertyDerivationCollection, PropertyDerivationEntry } from './property-derivation-types';\nimport { PropertyOverrideStore } from './property-override-store';\n\n/** Context required for applying property derivations. */\nexport interface PropertyDerivationApplicatorContext {\n /** The current form value (signal) */\n formValue: Signal<Record<string, unknown>>;\n\n /** The property override store to write to */\n store: PropertyOverrideStore;\n\n /** Registered derivation functions (used for property derivations with `functionName`) */\n derivationFunctions?: Record<string, CustomFunction>;\n\n /** Custom functions for expression evaluation */\n customFunctions?: Record<string, CustomFunction>;\n\n /** External data resolved from form config signals */\n externalData?: Record<string, unknown>;\n\n /** Logger for diagnostic output */\n logger: Logger;\n\n /** Warning tracker to prevent log spam */\n warningTracker?: WarningTracker;\n}\n\n/** Result of property derivation processing. */\nexport interface PropertyDerivationProcessingResult {\n /** Number of property derivations successfully applied */\n appliedCount: number;\n /** Number of property derivations skipped */\n skippedCount: number;\n /** Number of property derivations that failed */\n errorCount: number;\n}\n\n/** @internal */\nconst ERROR_PREFIX = '[PropertyDerivation]';\n\n/**\n * Applies all property derivation entries from a collection.\n *\n * @param collection - The collected property derivation entries\n * @param context - Context for applying property derivations\n * @param changedFields - Optional set of changed field keys for filtering\n * @returns Result of the processing\n */\nexport function applyPropertyDerivations(\n collection: PropertyDerivationCollection,\n context: PropertyDerivationApplicatorContext,\n changedFields?: Set<string>,\n): PropertyDerivationProcessingResult {\n let appliedCount = 0;\n let skippedCount = 0;\n let errorCount = 0;\n\n const baseEntries = changedFields ? getEntriesForChangedFields(collection.entries, changedFields) : collection.entries;\n // Async entries (HTTP / asyncFn / asyncFunctionName) are processed by dedicated\n // stream subscriptions in the orchestrator, not by this synchronous applicator.\n const entriesToProcess = baseEntries.filter((entry) => !isAsyncPropertyDerivationEntry(entry));\n\n for (const entry of entriesToProcess) {\n try {\n const applied = tryApplyPropertyDerivation(entry, context);\n if (applied) {\n appliedCount++;\n } else {\n skippedCount++;\n }\n } catch (error) {\n errorCount++;\n const errorMessage = error instanceof Error ? error.message : String(error);\n context.logger.error(\n `${ERROR_PREFIX} Failed to process property derivation (field: ${entry.fieldKey}, property: ${entry.targetProperty}): ${errorMessage}`,\n );\n }\n }\n\n return { appliedCount, skippedCount, errorCount };\n}\n\n/**\n * Applies property derivations filtered by trigger type.\n *\n * @param collection - The collected property derivation entries\n * @param trigger - The trigger type to filter by\n * @param context - Context for applying property derivations\n * @param changedFields - Optional set of changed field keys for filtering\n * @returns Result of the processing\n */\nexport function applyPropertyDerivationsForTrigger(\n collection: PropertyDerivationCollection,\n trigger: 'onChange' | 'debounced',\n context: PropertyDerivationApplicatorContext,\n changedFields?: Set<string>,\n): PropertyDerivationProcessingResult {\n const filteredEntries = collection.entries.filter((entry) => {\n if (trigger === 'onChange') {\n return !entry.trigger || entry.trigger === 'onChange';\n }\n return entry.trigger === trigger;\n });\n\n const filteredCollection: PropertyDerivationCollection = { entries: filteredEntries };\n return applyPropertyDerivations(filteredCollection, context, changedFields);\n}\n\n/**\n * Filters entries based on changed fields.\n *\n * @internal\n */\nfunction getEntriesForChangedFields(entries: PropertyDerivationEntry[], changedFields: Set<string>): PropertyDerivationEntry[] {\n return entries.filter((entry) => {\n if (entry.dependsOn.includes('*')) return true;\n return entry.dependsOn.some((dep) => changedFields.has(dep));\n });\n}\n\n/**\n * Attempts to apply a single property derivation entry.\n *\n * @returns true if the override was applied, false if skipped\n * @internal\n */\nfunction tryApplyPropertyDerivation(entry: PropertyDerivationEntry, context: PropertyDerivationApplicatorContext): boolean {\n // Handle array field derivations\n if (isArrayPlaceholderPath(entry.fieldKey)) {\n return tryApplyArrayPropertyDerivation(entry, context);\n }\n\n const formValue = untracked(() => context.formValue());\n const evalContext = createEvaluationContext(entry, formValue, context);\n\n // Evaluate condition\n if (!evaluatePropertyCondition(entry.condition, evalContext)) {\n return false;\n }\n\n // Compute the derived property value\n const newValue = computePropertyValue(entry, evalContext, context);\n\n // Write to the store\n context.store.setOverride(entry.fieldKey, entry.targetProperty, newValue);\n return true;\n}\n\n/**\n * Handles array field property derivations with '$' placeholder.\n *\n * @internal\n */\nfunction tryApplyArrayPropertyDerivation(entry: PropertyDerivationEntry, context: PropertyDerivationApplicatorContext): boolean {\n const formValue = untracked(() => context.formValue());\n\n const pathInfo = parseArrayPath(entry.fieldKey);\n if (!pathInfo.isArrayPath) {\n return false;\n }\n\n const { arrayPath } = pathInfo;\n const arrayValue = getNestedValue(formValue, arrayPath);\n if (!Array.isArray(arrayValue)) {\n return false;\n }\n\n let appliedAny = false;\n\n for (let i = 0; i < arrayValue.length; i++) {\n const resolvedPath = resolveArrayPath(entry.fieldKey, i);\n const arrayItem = arrayValue[i] as Record<string, unknown>;\n const evalContext = createArrayItemEvaluationContext(entry, arrayItem, formValue, i, arrayPath, context);\n\n if (!evaluatePropertyCondition(entry.condition, evalContext)) {\n continue;\n }\n\n try {\n const newValue = computePropertyValue(entry, evalContext, context);\n context.store.setOverride(resolvedPath, entry.targetProperty, newValue);\n appliedAny = true;\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n context.logger.error(\n `${ERROR_PREFIX} Failed to compute property derivation (field: ${entry.fieldKey}, property: ${entry.targetProperty}, index: ${i}): ${errorMessage}`,\n );\n }\n }\n\n return appliedAny;\n}\n\n/**\n * Creates an evaluation context for property derivation processing.\n *\n * @internal\n */\nfunction createEvaluationContext(\n entry: PropertyDerivationEntry,\n formValue: Record<string, unknown>,\n context: PropertyDerivationApplicatorContext,\n): EvaluationContext {\n const fieldValue = getNestedValue(formValue, entry.fieldKey);\n\n // Parent group value: the absolute path with the trailing field segment\n // dropped. `undefined` when the field is at form root.\n const parentPath = getParentPathInScope(entry.fieldKey);\n const groupValue = parentPath ? getNestedValue(formValue, parentPath) : undefined;\n\n return {\n fieldValue,\n formValue,\n fieldPath: entry.fieldKey,\n groupValue,\n customFunctions: context.customFunctions,\n externalData: context.externalData,\n logger: context.logger,\n };\n}\n\n/**\n * Creates an evaluation context scoped to a specific array item.\n *\n * @internal\n */\nfunction createArrayItemEvaluationContext(\n entry: PropertyDerivationEntry,\n arrayItem: Record<string, unknown>,\n rootFormValue: Record<string, unknown>,\n itemIndex: number,\n arrayPath: string,\n context: PropertyDerivationApplicatorContext,\n): EvaluationContext {\n const pathInfo = parseArrayPath(entry.fieldKey);\n const { relativePath, groupValue, fieldValue } = resolveArrayItemScope(pathInfo, arrayItem);\n\n return {\n fieldValue,\n formValue: arrayItem,\n fieldPath: relativePath ? `${arrayPath}.${itemIndex}.${relativePath}` : `${arrayPath}.${itemIndex}`,\n groupValue,\n customFunctions: context.customFunctions,\n externalData: context.externalData,\n logger: context.logger,\n rootFormValue,\n arrayIndex: itemIndex,\n arrayPath,\n };\n}\n\n/**\n * Evaluates the property derivation condition.\n *\n * @internal\n */\nfunction evaluatePropertyCondition(condition: ConditionalExpression | boolean, context: EvaluationContext): boolean {\n if (typeof condition === 'boolean') {\n return condition;\n }\n return evaluateCondition(condition, context);\n}\n\n/**\n * Computes the derived property value based on the entry configuration.\n *\n * @internal\n */\nfunction computePropertyValue(\n entry: PropertyDerivationEntry,\n evalContext: EvaluationContext,\n applicatorContext: PropertyDerivationApplicatorContext,\n): unknown {\n return computeValueFromEntry(entry, evalContext, {\n derivationFunctions: applicatorContext.derivationFunctions,\n subject: `${entry.fieldKey}.${entry.targetProperty}`,\n functionKind: 'Property derivation function',\n });\n}\n","import { HttpClient } from '@angular/common/http';\nimport { Signal, untracked } from '@angular/core';\nimport { EMPTY, Observable, debounceTime, filter, map, pairwise, startWith, switchMap } from 'rxjs';\nimport { catchError } from 'rxjs/operators';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport { getChangedKeys } from '@ng-forge/dynamic-forms/internal';\nimport { isArrayPlaceholderPath } from '../../utils/path-utils/path-utils';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport { evaluateCondition } from '@ng-forge/dynamic-forms/internal';\nimport { ExpressionParser } from '@ng-forge/dynamic-forms/internal';\nimport { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { resolveHttpRequest } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { PropertyDerivationEntry } from './property-derivation-types';\nimport { PropertyOverrideStore } from './property-override-store';\n\n/**\n * Context required for creating HTTP property-derivation streams.\n *\n * @internal\n */\nexport interface HttpPropertyDerivationStreamContext {\n /** Signal containing the current form value */\n formValue: Signal<Record<string, unknown>>;\n\n /** The property override store to write into */\n store: PropertyOverrideStore;\n\n /** Angular HttpClient for making requests */\n httpClient: HttpClient;\n\n /** Logger for diagnostic output */\n logger: Logger;\n\n /** Lazy resolver for custom functions — called per-emission to get fresh values */\n customFunctions?: () => Record<string, CustomFunction> | undefined;\n\n /** Lazy resolver for external data — called per-emission to get fresh values */\n externalData?: () => Record<string, unknown> | undefined;\n}\n\nconst LOG_PREFIX = 'HTTP Property Derivation -';\nconst DEFAULT_HTTP_DEBOUNCE_MS = 300;\n\n/**\n * Creates an RxJS Observable stream that processes an HTTP property-derivation entry.\n *\n * @internal\n */\nexport function createHttpPropertyDerivationStream(\n entry: PropertyDerivationEntry,\n formValue$: Observable<Record<string, unknown>>,\n context: HttpPropertyDerivationStreamContext,\n): Observable<void> {\n // Guard: no array support yet for HTTP property derivations (mirrors value pipeline).\n if (isArrayPlaceholderPath(entry.fieldKey)) {\n context.logger.warn(\n `${LOG_PREFIX} HTTP property derivation for array field '${entry.fieldKey}' is not supported. ` +\n `Array HTTP property derivations will be supported in a future release.`,\n );\n return EMPTY;\n }\n\n if (!entry.http || !entry.responseExpression) {\n return EMPTY;\n }\n\n const responseExpression = entry.responseExpression;\n const debounceMs = entry.debounceMs ?? DEFAULT_HTTP_DEBOUNCE_MS;\n\n return formValue$.pipe(\n startWith(null as Record<string, unknown> | null),\n pairwise(),\n map(([previous, current]) => ({\n current: current!,\n changedFields: getChangedKeys(previous, current!),\n })),\n filter(({ changedFields }) => {\n if (changedFields.size === 0) return false;\n return entry.dependsOn.some((dep) => changedFields.has(dep));\n }),\n debounceTime(debounceMs),\n switchMap(({ current }) => {\n return new Observable<void>((subscriber) => {\n const customFunctions = context.customFunctions?.();\n const externalData = context.externalData?.();\n\n const fieldValue = getNestedValue(current, entry.fieldKey);\n\n const evalContext: EvaluationContext = {\n fieldValue,\n formValue: current,\n fieldPath: entry.fieldKey,\n customFunctions,\n externalData,\n logger: context.logger,\n };\n\n // Evaluate condition — skip if false\n if (entry.condition !== true) {\n const conditionMet = typeof entry.condition === 'boolean' ? entry.condition : evaluateCondition(entry.condition, evalContext);\n if (!conditionMet) {\n subscriber.complete();\n return;\n }\n }\n\n // Returns null when a path param is undefined — suppress the request\n const resolvedRequest = resolveHttpRequest(entry.http!, evalContext);\n if (!resolvedRequest) {\n subscriber.complete();\n return;\n }\n\n const method = (resolvedRequest.method ?? 'GET').toUpperCase();\n // Inner cancellation is handled declaratively by the orchestrator's\n // outer switchMap: when the entry set changes, the outer stream\n // unsubscribes from this inner observable, which triggers the\n // cleanup callback below and cancels the in-flight HTTP request.\n const httpSub = context.httpClient\n .request(method, resolvedRequest.url, {\n body: resolvedRequest.body,\n headers: resolvedRequest.headers as Record<string, string>,\n })\n .subscribe({\n next: (response) => {\n try {\n const newValue = ExpressionParser.evaluate(responseExpression, { response });\n\n // `setOverride` performs its own deep-equality check, so we don't need to\n // compare with the current store value here.\n untracked(() => context.store.setOverride(entry.fieldKey, entry.targetProperty, newValue));\n\n subscriber.complete();\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Failed to process response for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);\n subscriber.complete();\n }\n },\n error: (error) => {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} HTTP request failed for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);\n subscriber.complete();\n },\n });\n\n return () => {\n httpSub.unsubscribe();\n };\n });\n }),\n catchError((error) => {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Unexpected stream error for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);\n return EMPTY;\n }),\n );\n}\n","import { Signal, untracked } from '@angular/core';\nimport { EMPTY, from, Observable, debounceTime, filter, map, pairwise, startWith, switchMap } from 'rxjs';\nimport { catchError } from 'rxjs/operators';\nimport { EvaluationContext } from '@ng-forge/dynamic-forms/internal';\nimport { getChangedKeys } from '@ng-forge/dynamic-forms/internal';\nimport { isArrayPlaceholderPath } from '../../utils/path-utils/path-utils';\nimport { CustomFunction } from '@ng-forge/dynamic-forms/internal';\nimport type { AsyncDerivationFunction } from '@ng-forge/dynamic-forms/internal';\nimport { evaluateCondition } from '@ng-forge/dynamic-forms/internal';\nimport { getNestedValue } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { PropertyDerivationEntry } from './property-derivation-types';\nimport { PropertyOverrideStore } from './property-override-store';\n\n/**\n * Context required for creating async-function property-derivation streams.\n *\n * @internal\n */\nexport interface AsyncPropertyDerivationStreamContext {\n /** Signal containing the current form value */\n formValue: Signal<Record<string, unknown>>;\n\n /** The property override store to write into */\n store: PropertyOverrideStore;\n\n /** Logger for diagnostic output */\n logger: Logger;\n\n /** Lazy resolver for custom functions — called per-emission to get fresh values */\n customFunctions?: () => Record<string, CustomFunction> | undefined;\n\n /** Lazy resolver for async derivation functions — called per-emission to get fresh values */\n asyncDerivationFunctions?: () => Record<string, AsyncDerivationFunction> | undefined;\n\n /** Lazy resolver for external data — called per-emission to get fresh values */\n externalData?: () => Record<string, unknown> | undefined;\n}\n\nconst LOG_PREFIX = 'Async Property Derivation -';\nconst DEFAULT_ASYNC_DEBOUNCE_MS = 300;\n\n/**\n * Creates an RxJS Observable stream that processes an async-function property-derivation entry.\n *\n * @internal\n */\nexport function createAsyncPropertyDerivationStream(\n entry: PropertyDerivationEntry,\n formValue$: Observable<Record<string, unknown>>,\n context: AsyncPropertyDerivationStreamContext,\n): Observable<void> {\n if (isArrayPlaceholderPath(entry.fieldKey)) {\n context.logger.warn(\n `${LOG_PREFIX} Async property derivation for array field '${entry.fieldKey}' is not supported. ` +\n `Array async property derivations will be supported in a future release.`,\n );\n return EMPTY;\n }\n\n if (!entry.asyncFunctionName && !entry.asyncFn) {\n context.logger.warn(\n `${LOG_PREFIX} Property derivation for '${entry.fieldKey}.${entry.targetProperty}' has neither \"asyncFunctionName\" nor \"asyncFn\". ` +\n `One of the two is required for source: 'asyncFunction'.`,\n );\n return EMPTY;\n }\n\n if (entry.asyncFn && entry.asyncFunctionName) {\n context.logger.warn(\n `${LOG_PREFIX} Both \"asyncFn\" and \"asyncFunctionName\" are set on property derivation for '${entry.fieldKey}.${entry.targetProperty}'. ` +\n `Inline \"asyncFn\" takes precedence; \"asyncFunctionName\" is ignored.`,\n );\n }\n\n const inlineAsyncFn = entry.asyncFn;\n const asyncFunctionName = entry.asyncFunctionName;\n const debounceMs = entry.debounceMs ?? DEFAULT_ASYNC_DEBOUNCE_MS;\n\n return formValue$.pipe(\n startWith(null as Record<string, unknown> | null),\n pairwise(),\n map(([previous, current]) => ({\n current: current!,\n changedFields: getChangedKeys(previous, current!),\n })),\n filter(({ changedFields }) => {\n if (changedFields.size === 0) return false;\n return entry.dependsOn.some((dep) => changedFields.has(dep));\n }),\n debounceTime(debounceMs),\n switchMap(({ current }) => {\n return new Observable<void>((subscriber) => {\n const customFunctions = context.customFunctions?.();\n const asyncDerivationFunctions = context.asyncDerivationFunctions?.();\n const externalData = context.externalData?.();\n\n const asyncFn = inlineAsyncFn ?? (asyncFunctionName ? asyncDerivationFunctions?.[asyncFunctionName] : undefined);\n if (!asyncFn) {\n context.logger.warn(\n `${LOG_PREFIX} Async derivation function '${asyncFunctionName}' not found for '${entry.fieldKey}.${entry.targetProperty}'. ` +\n `Register it in customFnConfig.asyncDerivations.`,\n );\n subscriber.complete();\n return;\n }\n\n const fieldValue = getNestedValue(current, entry.fieldKey);\n\n const evalContext: EvaluationContext = {\n fieldValue,\n formValue: current,\n fieldPath: entry.fieldKey,\n customFunctions,\n externalData,\n logger: context.logger,\n };\n\n if (entry.condition !== true) {\n const conditionMet = typeof entry.condition === 'boolean' ? entry.condition : evaluateCondition(entry.condition, evalContext);\n if (!conditionMet) {\n subscriber.complete();\n return;\n }\n }\n\n const result$ = from(asyncFn(evalContext));\n\n const asyncSub = result$.subscribe({\n next: (newValue) => {\n try {\n untracked(() => context.store.setOverride(entry.fieldKey, entry.targetProperty, newValue));\n subscriber.complete();\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Failed to process result for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);\n subscriber.complete();\n }\n },\n error: (error) => {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Async function failed for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);\n subscriber.complete();\n },\n complete: () => {\n subscriber.complete();\n },\n });\n\n return () => {\n asyncSub.unsubscribe();\n };\n });\n }),\n catchError((error) => {\n const message = error instanceof Error ? error.message : String(error);\n context.logger.warn(`${LOG_PREFIX} Unexpected stream error for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);\n return EMPTY;\n }),\n );\n}\n","import { HttpClient } from '@angular/common/http';\nimport { computed, DestroyRef, inject, Injector, Signal, untracked } from '@angular/core';\nimport { DEV_MODE } from '../../utils/dev-mode';\nimport { toObservable } from '@angular/core/rxjs-interop';\nimport { FieldTree } from '@angular/forms/signals';\nimport { explicitEffect } from 'ngxtension/explicit-effect';\nimport { FieldDef } from '@ng-forge/dynamic-forms/internal';\nimport { FORM_OPTIONS } from '@ng-forge/dynamic-forms/internal';\nimport type { FormOptions } from '@ng-forge/dynamic-forms/internal';\nimport { DynamicFormLogger } from '@ng-forge/dynamic-forms/internal';\nimport { Logger } from '@ng-forge/dynamic-forms/internal';\nimport { DEFAULT_DEBOUNCE_MS } from '../../utils/debounce/debounce';\nimport { FunctionRegistryService } from '../registry';\nimport { applyDerivationsForTrigger } from './derivation-applicator';\nimport { collectAllDerivations, collectDerivations } from './derivation-collector';\nimport { collectPropertyDerivations } from '../property-derivation/property-derivation-collector';\nimport { validateNoCycles } from './cycle-detector';\nimport { DerivationCollection, DerivationEntry } from './derivation-types';\nimport { DerivationLogger } from './derivation-logger.service';\nimport { DERIVATION_WARNING_TRACKER } from './derivation-warning-tracker';\nimport { createHttpDerivationStream, HttpDerivationStreamContext } from './http-derivation-stream';\nimport { createAsyncDerivationStream } from './async-derivation-stream';\nimport { resolveExternalData } from './external-data-resolver';\nimport { setupDebouncedStream, setupEntryAsyncStream, setupOnChangeStream } from './pipeline-setup-utils';\nimport { applyPropertyDerivationsForTrigger } from '../property-derivation/property-derivation-applicator';\nimport { PropertyDerivationCollection, PropertyDerivationEntry } from '../property-derivation/property-derivation-types';\nimport { PropertyOverrideStore } from '../property-derivation/property-override-store';\nimport { createHttpPropertyDerivationStream } from '../property-derivation/http-property-derivation-stream';\nimport { createAsyncPropertyDerivationStream } from '../property-derivation/async-property-derivation-stream';\n\n/**\n * Configuration for creating a DerivationOrchestrator. Both pipelines are\n * opt-in via their respective config fields:\n */\nexport interface DerivationOrchestratorConfig {\n /** Signal containing the flattened schema fields */\n schemaFields: Signal<FieldDef<unknown>[] | undefined>;\n\n /** Signal containing the current form value */\n formValue: Signal<Record<string, unknown>>;\n\n /**\n * Signal containing the form accessor. **Required for the value pipeline.**\n * Omit (together with `derivationLogger`) to skip value-derivation setup.\n */\n form?: Signal<FieldTree<unknown>>;\n\n /**\n * Signal containing the derivation logger. **Required for the value\n * pipeline.** Omit (together with `form`) to skip value-derivation setup.\n */\n derivationLogger?: Signal<DerivationLogger>;\n\n /**\n * Property override store to write derived hidden/disabled/readonly states\n * into. **Required for the property pipeline.** Omit to skip\n * property-derivation setup.\n */\n propertyStore?: PropertyOverrideStore;\n\n /** Signal containing external data signals for expression evaluation */\n externalData?: Signal<Record<string, Signal<unknown>> | undefined>;\n}\n\n/**\n * Orchestrates derivation processing for a dynamic form — both **value\n * derivations** (writes into the form's FieldTree) and **property derivations**\n * (writes into a property override store). Each pipeline opts in via the\n * corresponding fields of {@link DerivationOrchestratorConfig}; an orchestrator\n * instance can run one, both, or (degenerately) neither.\n */\nexport class DerivationOrchestrator {\n private readonly config: DerivationOrchestratorConfig;\n private readonly injector = inject(Injector);\n private readonly destroyRef = inject(DestroyRef);\n private readonly logger = inject(DynamicFormLogger);\n private readonly warningTracker = inject(DERIVATION_WARNING_TRACKER);\n private readonly functionRegistry = inject(FunctionRegistryService);\n private readonly httpClient = inject(HttpClient, { optional: true });\n\n // Pipeline-specific deps are injected lazily inside the per-pipeline setup\n // methods so the unused token's provider isn't required when only one\n // pipeline is active (matters for tests + the future\n // withPropertyDerivations() / withDerivations() split). The value-pipeline\n // apply helpers read `this.formOptions` directly; the definite-assignment\n // assertion is safe because those helpers only fire from streams wired by\n // setupValuePipeline(), which assigns the signal first.\n private formOptions!: Signal<FormOptions | undefined>;\n\n /**\n * Computed signal containing the collected and validated value derivations.\n * Returns null when no value derivations are defined OR the value pipeline\n * was not activated (no `form`/`derivationLogger` in config).\n */\n readonly derivationCollection: Signal<DerivationCollection | null>;\n\n /**\n * Computed signal containing the collected property derivations. Returns\n * null when no property derivations are defined OR the property pipeline\n * was not activated (no `propertyStore` in config).\n */\n readonly propertyDerivationCollection: Signal<PropertyDerivationCollection | null>;\n\n constructor(config: DerivationOrchestratorConfig) {\n this.config = config;\n\n const valueEnabled = !!(config.form && config.derivationLogger);\n const propertyStore = config.propertyStore;\n const propertyEnabled = !!propertyStore;\n\n // Shared single-traversal collector — built only when BOTH pipelines are\n // active. When only one pipeline is active that pipeline's setup runs the\n // slice-specific collector directly. The memoization assumption: in the\n // common case where both pipeline signals are pulled in the same Angular\n // change-detection cycle, the shared computed runs once and both\n // consumers reuse the result. If only one consumer pulls within a frame,\n // the work is identical to the slice-collector path — no extra cost.\n const sharedAllDerivations =\n valueEnabled && propertyEnabled\n ? computed(() => {\n const fields = config.schemaFields();\n if (!fields || fields.length === 0) return null;\n return collectAllDerivations(fields);\n })\n : null;\n\n this.derivationCollection = valueEnabled\n ? this.setupValuePipeline(sharedAllDerivations)\n : computed<DerivationCollection | null>(() => null);\n\n this.propertyDerivationCollection = propertyStore\n ? this.setupPropertyPipeline(propertyStore, sharedAllDerivations)\n : computed<PropertyDerivationCollection | null>(() => null);\n }\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Value pipeline — setup\n // ─────────────────────────────────────────────────────────────────────────────\n\n private setupValuePipeline(\n sharedAllDerivations: Signal<{ value: DerivationCollection; property: PropertyDerivationCollection } | null> | null,\n ): Signal<DerivationCollection | null> {\n this.formOptions = inject(FORM_OPTIONS);\n\n const collection = computed<DerivationCollection | null>(() => {\n const fields = this.config.schemaFields();\n\n if (!fields || fields.length === 0) {\n return null;\n }\n\n // When both pipelines run, reuse the shared traversal; otherwise this\n // pipeline owns the walk (no property tracker needed).\n const collected = sharedAllDerivations ? (sharedAllDerivations()?.value ?? null) : collectDerivations(fields);\n\n if (!collected || collected.entries.length === 0) {\n return null;\n }\n\n validateNoCycles(collected, this.logger);\n if (DEV_MODE) {\n warnAboutWildcardDependencies(this.logger, collected.entries, fields.length);\n warnAboutMisconfiguredReEngagement(this.logger, collected.entries);\n }\n\n return collected;\n });\n\n setupOnChangeStream<DerivationCollection>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'Derivation onChange stream error',\n collectionSignal: collection,\n formValueSignal: this.config.formValue,\n // The form accessor signal must also wake the stream so that config swaps\n // (which produce a new FieldTree) trigger a re-application of derivations\n // against the freshly-built form.\n additionalAwakeners: [this.config.form!],\n applyOnChange: (current, changedFields) => {\n this.applyOnChangeDerivations(\n current,\n untracked(() => this.config.form!()),\n changedFields,\n );\n },\n });\n\n setupDebouncedStream<DerivationCollection>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'Derivation debounced stream error',\n collectionSignal: collection,\n formValueSignal: this.config.formValue,\n applyDebouncedForPeriod: (debounceMs, current, changedFields) => {\n const formAccessor = untracked(() => this.config.form!());\n if (!formAccessor) return;\n this.applyDebouncedEntriesForPeriod(debounceMs, current, formAccessor, changedFields);\n },\n });\n\n this.setupValueHttpStreams(collection);\n this.setupValueAsyncFunctionStreams(collection);\n\n return collection;\n }\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Property pipeline — setup\n // ─────────────────────────────────────────────────────────────────────────────\n\n private setupPropertyPipeline(\n propertyStore: PropertyOverrideStore,\n sharedAllDerivations: Signal<{ value: DerivationCollection; property: PropertyDerivationCollection } | null> | null,\n ): Signal<PropertyDerivationCollection | null> {\n const collection = computed<PropertyDerivationCollection | null>(() => {\n const fields = this.config.schemaFields();\n\n if (!fields || fields.length === 0) {\n return null;\n }\n\n // When both pipelines run, reuse the shared traversal; otherwise this\n // pipeline owns the walk.\n const collected = sharedAllDerivations ? (sharedAllDerivations()?.property ?? null) : collectPropertyDerivations(fields);\n\n if (!collected || collected.entries.length === 0) {\n return null;\n }\n\n return collected;\n });\n\n // Side effect: clear store + register fields whenever the collection changes.\n // schemaFields is in the dep array so the field count is always in sync.\n explicitEffect([collection, this.config.schemaFields], ([current, fields]) => {\n propertyStore.clear();\n\n if (!current) return;\n\n for (const entry of current.entries) {\n propertyStore.registerField(entry.fieldKey);\n }\n\n if (DEV_MODE) {\n warnAboutWildcardPropertyDependencies(this.logger, current.entries, fields?.length ?? 0);\n }\n });\n\n setupOnChangeStream<PropertyDerivationCollection>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'Property derivation onChange stream error',\n collectionSignal: collection,\n formValueSignal: this.config.formValue,\n // Property pipeline's onChange historically ignored the changed-fields set\n // (it always re-runs all 'onChange' entries). The helper computes one anyway;\n // we drop it here to preserve the original behavior.\n applyOnChange: (current) => this.applyOnChangePropertyDerivations(current, propertyStore),\n });\n\n setupDebouncedStream<PropertyDerivationCollection>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'Property derivation debounced stream error',\n collectionSignal: collection,\n formValueSignal: this.config.formValue,\n applyDebouncedForPeriod: (debounceMs, current, changedFields) => {\n this.applyDebouncedPropertyEntriesForPeriod(debounceMs, current, propertyStore, changedFields);\n },\n });\n\n this.setupPropertyHttpStreams(collection, propertyStore);\n this.setupPropertyAsyncFunctionStreams(collection, propertyStore);\n\n return collection;\n }\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Value pipeline — apply helpers\n // ─────────────────────────────────────────────────────────────────────────────\n\n private applyOnChangeDerivations(collection: DerivationCollection, formAccessor: FieldTree<unknown>, changedFields?: Set<string>): void {\n const applicatorContext = {\n formValue: this.config.formValue,\n rootForm: formAccessor,\n derivationFunctions: this.functionRegistry.getDerivationFunctions(),\n customFunctions: this.functionRegistry.getCustomFunctions(),\n externalData: resolveExternalData(this.config.externalData),\n logger: this.logger,\n warningTracker: this.warningTracker,\n derivationLogger: untracked(() => this.config.derivationLogger!()),\n maxIterations: untracked(() => this.formOptions()?.maxDerivationIterations),\n };\n\n const result = applyDerivationsForTrigger(collection, 'onChange', applicatorContext, changedFields);\n\n if (result.maxIterationsReached) {\n this.logger.warn(\n `Derivation processing reached max iterations. ` +\n `This may indicate a loop in derivation logic that wasn't caught at build time. ` +\n `Applied: ${result.appliedCount}, Skipped: ${result.skippedCount}, Errors: ${result.errorCount}`,\n );\n }\n }\n\n private applyDebouncedEntriesForPeriod(\n debounceMs: number,\n collection: DerivationCollection,\n formAccessor: FieldTree<unknown>,\n changedFields?: Set<string>,\n ): void {\n const debouncedEntries = collection.entries.filter(\n (entry) => entry.trigger === 'debounced' && (entry.debounceMs ?? DEFAULT_DEBOUNCE_MS) === debounceMs,\n );\n\n if (debouncedEntries.length === 0) {\n return;\n }\n\n const filteredCollection: DerivationCollection = {\n entries: debouncedEntries,\n };\n\n const applicatorContext = {\n formValue: this.config.formValue,\n rootForm: formAccessor,\n derivationFunctions: this.functionRegistry.getDerivationFunctions(),\n customFunctions: this.functionRegistry.getCustomFunctions(),\n externalData: resolveExternalData(this.config.externalData),\n logger: this.logger,\n warningTracker: this.warningTracker,\n derivationLogger: untracked(() => this.config.derivationLogger!()),\n maxIterations: untracked(() => this.formOptions()?.maxDerivationIterations),\n };\n\n const result = applyDerivationsForTrigger(filteredCollection, 'debounced', applicatorContext, changedFields);\n\n if (result.maxIterationsReached) {\n this.logger.warn(\n `Debounced derivation processing reached max iterations (${debounceMs}ms). ` +\n `Applied: ${result.appliedCount}, Skipped: ${result.skippedCount}, Errors: ${result.errorCount}`,\n );\n }\n }\n\n private setupValueHttpStreams(collectionSignal: Signal<DerivationCollection | null>): void {\n const formValue$ = toObservable(this.config.formValue, { injector: this.injector });\n\n setupEntryAsyncStream<DerivationCollection, DerivationEntry>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'HTTP Derivation: outer stream error',\n collectionSignal,\n selectEntries: (collection) => collection?.entries.filter((entry) => entry.http) ?? [],\n entrySignature: valueHttpEntrySignature,\n createStream: (entry) => {\n if (!this.httpClient) {\n this.logger.error(\n 'HTTP Derivation: HttpClient is not available. Ensure provideHttpClient() is included in your application providers.',\n );\n return null;\n }\n const context: HttpDerivationStreamContext = {\n formValue: this.config.formValue,\n form: this.config.form!,\n httpClient: this.httpClient,\n logger: this.logger,\n derivationLogger: this.config.derivationLogger!,\n customFunctions: () => this.functionRegistry.getCustomFunctions(),\n externalData: () => resolveExternalData(this.config.externalData),\n warningTracker: this.warningTracker,\n };\n return createHttpDerivationStream(entry, formValue$, context);\n },\n });\n }\n\n private setupValueAsyncFunctionStreams(collectionSignal: Signal<DerivationCollection | null>): void {\n const formValue$ = toObservable(this.config.formValue, { injector: this.injector });\n\n setupEntryAsyncStream<DerivationCollection, DerivationEntry>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'Async Derivation: outer stream error',\n collectionSignal,\n selectEntries: (collection) => collection?.entries.filter((entry) => entry.asyncFunctionName || entry.asyncFn) ?? [],\n entrySignature: valueAsyncEntrySignature,\n createStream: (entry) =>\n createAsyncDerivationStream(entry, formValue$, {\n formValue: this.config.formValue,\n form: this.config.form!,\n logger: this.logger,\n derivationLogger: this.config.derivationLogger!,\n customFunctions: () => this.functionRegistry.getCustomFunctions(),\n asyncDerivationFunctions: () => this.functionRegistry.getAsyncDerivationFunctions(),\n externalData: () => resolveExternalData(this.config.externalData),\n warningTracker: this.warningTracker,\n }),\n });\n }\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Property pipeline — apply helpers\n // ─────────────────────────────────────────────────────────────────────────────\n\n private applyOnChangePropertyDerivations(collection: PropertyDerivationCollection, store: PropertyOverrideStore): void {\n const applicatorContext = {\n formValue: this.config.formValue,\n store,\n derivationFunctions: this.functionRegistry.getDerivationFunctions(),\n customFunctions: this.functionRegistry.getCustomFunctions(),\n externalData: resolveExternalData(this.config.externalData),\n logger: this.logger,\n warningTracker: this.warningTracker,\n };\n\n applyPropertyDerivationsForTrigger(collection, 'onChange', applicatorContext);\n }\n\n private applyDebouncedPropertyEntriesForPeriod(\n debounceMs: number,\n collection: PropertyDerivationCollection,\n store: PropertyOverrideStore,\n changedFields?: Set<string>,\n ): void {\n const debouncedEntries = collection.entries.filter(\n (entry) => entry.trigger === 'debounced' && (entry.debounceMs ?? DEFAULT_DEBOUNCE_MS) === debounceMs,\n );\n\n if (debouncedEntries.length === 0) return;\n\n const filteredCollection: PropertyDerivationCollection = { entries: debouncedEntries };\n\n const applicatorContext = {\n formValue: this.config.formValue,\n store,\n derivationFunctions: this.functionRegistry.getDerivationFunctions(),\n customFunctions: this.functionRegistry.getCustomFunctions(),\n externalData: resolveExternalData(this.config.externalData),\n logger: this.logger,\n warningTracker: this.warningTracker,\n };\n\n applyPropertyDerivationsForTrigger(filteredCollection, 'debounced', applicatorContext, changedFields);\n }\n\n private setupPropertyHttpStreams(collectionSignal: Signal<PropertyDerivationCollection | null>, store: PropertyOverrideStore): void {\n const formValue$ = toObservable(this.config.formValue, { injector: this.injector });\n\n setupEntryAsyncStream<PropertyDerivationCollection, PropertyDerivationEntry>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'HTTP Property Derivation: outer stream error',\n collectionSignal,\n selectEntries: (collection) => collection?.entries.filter((entry) => entry.http) ?? [],\n entrySignature: propertyHttpEntrySignature,\n createStream: (entry) => {\n if (!this.httpClient) {\n this.logger.error(\n 'HTTP Property Derivation: HttpClient is not available. Ensure provideHttpClient() is included in your application providers.',\n );\n return null;\n }\n return createHttpPropertyDerivationStream(entry, formValue$, {\n formValue: this.config.formValue,\n store,\n httpClient: this.httpClient,\n logger: this.logger,\n customFunctions: () => this.functionRegistry.getCustomFunctions(),\n externalData: () => resolveExternalData(this.config.externalData),\n });\n },\n });\n }\n\n private setupPropertyAsyncFunctionStreams(\n collectionSignal: Signal<PropertyDerivationCollection | null>,\n store: PropertyOverrideStore,\n ): void {\n const formValue$ = toObservable(this.config.formValue, { injector: this.injector });\n\n setupEntryAsyncStream<PropertyDerivationCollection, PropertyDerivationEntry>({\n injector: this.injector,\n destroyRef: this.destroyRef,\n logger: this.logger,\n errorLabel: 'Async Property Derivation: outer stream error',\n collectionSignal,\n selectEntries: (collection) => collection?.entries.filter((entry) => entry.asyncFunctionName || entry.asyncFn) ?? [],\n entrySignature: propertyAsyncEntrySignature,\n createStream: (entry) =>\n createAsyncPropertyDerivationStream(entry, formValue$, {\n formValue: this.config.formValue,\n store,\n logger: this.logger,\n customFunctions: () => this.functionRegistry.getCustomFunctions(),\n asyncDerivationFunctions: () => this.functionRegistry.getAsyncDerivationFunctions(),\n externalData: () => resolveExternalData(this.config.externalData),\n }),\n });\n }\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Entry signatures — value pipeline\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Identity signature for an HTTP value-derivation entry. Includes every field\n * that drives the inner stream's behavior — changing any of them must tear\n * down and rebuild the stream. Stable across topological reorderings because\n * the consumer ({@link entrySetsEqual}) compares as a multiset.\n */\nfunction valueHttpEntrySignature(entry: DerivationEntry): string {\n const config = {\n http: entry.http,\n responseExpression: entry.responseExpression,\n dependsOn: entry.dependsOn,\n debounceMs: entry.debounceMs,\n trigger: entry.trigger,\n condition: entry.condition,\n stopOnUserOverride: entry.stopOnUserOverride,\n reEngageOnDependencyChange: entry.reEngageOnDependencyChange,\n };\n return `${entry.fieldKey}:${JSON.stringify(config)}`;\n}\n\nfunction valueAsyncEntrySignature(entry: DerivationEntry): string {\n // For inline `asyncFn` entries we tag the signature with the field path (the\n // real identity, since derivations are self-targeting) rather than the array\n // index. Index-based tagging churned identities whenever topologicalSort\n // reordered entries, causing unnecessary teardown + re-subscribe.\n const config = {\n asyncFunctionName: entry.asyncFunctionName,\n asyncFnId: entry.asyncFn ? `inline:${entry.fieldKey}` : undefined,\n dependsOn: entry.dependsOn,\n debounceMs: entry.debounceMs,\n trigger: entry.trigger,\n condition: entry.condition,\n stopOnUserOverride: entry.stopOnUserOverride,\n reEngageOnDependencyChange: entry.reEngageOnDependencyChange,\n };\n return `${entry.fieldKey}:${JSON.stringify(config)}`;\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Entry signatures — property pipeline\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction propertyHttpEntrySignature(entry: PropertyDerivationEntry): string {\n const config = {\n http: entry.http,\n responseExpression: entry.responseExpression,\n dependsOn: entry.dependsOn,\n debounceMs: entry.debounceMs,\n trigger: entry.trigger,\n condition: entry.condition,\n };\n return `${entry.fieldKey}.${entry.targetProperty}:${JSON.stringify(config)}`;\n}\n\nfunction propertyAsyncEntrySignature(entry: PropertyDerivationEntry): string {\n const config = {\n asyncFunctionName: entry.asyncFunctionName,\n asyncFnId: entry.asyncFn ? `inline:${entry.fieldKey}.${entry.targetProperty}` : undefined,\n dependsOn: entry.dependsOn,\n debounceMs: entry.debounceMs,\n trigger: entry.trigger,\n condition: entry.condition,\n };\n return `${entry.fieldKey}.${entry.targetProperty}:${JSON.stringify(config)}`;\n}\n\n/**\n * Creates a DerivationOrchestrator for a dynamic form.\n * Must be called within an injection context.\n *\n * @param config - Form-specific signals configuration\n * @returns The created DerivationOrchestrator\n */\nexport function createDerivationOrchestrator(config: DerivationOrchestratorConfig): DerivationOrchestrator {\n return new DerivationOrchestrator(config);\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Dev-mode diagnostics (tree-shaken in prod via DEV_MODE gate at call site)\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction warnAboutWildcardDependencies(logger: Logger, entries: DerivationEntry[], fieldCount: number): void {\n const wildcardEntries = entries.filter((entry) => entry.dependsOn.includes('*'));\n if (wildcardEntries.length === 0) return;\n\n // HTTP and async entries require explicit dependsOn (validated at collection time)\n const implicitWildcards = wildcardEntries.filter(\n (entry) =>\n !entry.http &&\n !entry.asyncFunctionName &&\n !entry.asyncFn &&\n (entry.functionName || entry.fn) &&\n (!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0),\n );\n\n if (implicitWildcards.length > 0) {\n const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey} (${e.functionName ?? '<inline fn>'})`);\n logger.warn(\n 'Derivation: custom functions without explicit dependsOn detected. ' +\n `These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +\n 'Consider specifying explicit dependsOn arrays for better performance.',\n derivationDescs,\n );\n }\n}\n\nfunction warnAboutMisconfiguredReEngagement(logger: Logger, entries: DerivationEntry[]): void {\n const misconfigured = entries.filter((entry) => entry.reEngageOnDependencyChange && !entry.stopOnUserOverride);\n if (misconfigured.length === 0) return;\n\n const fieldKeys = misconfigured.map((e) => e.debugName ?? e.fieldKey);\n logger.warn(\n 'Derivation: reEngageOnDependencyChange without stopOnUserOverride detected. ' +\n 'reEngageOnDependencyChange only takes effect when stopOnUserOverride is true. ' +\n 'Either add stopOnUserOverride: true or remove reEngageOnDependencyChange.',\n fieldKeys,\n );\n}\n\nfunction warnAboutWildcardPropertyDependencies(logger: Logger, entries: PropertyDerivationEntry[], fieldCount: number): void {\n const implicitWildcards = entries.filter(\n (entry) =>\n entry.dependsOn.includes('*') &&\n (entry.functionName || entry.fn) &&\n (!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0),\n );\n\n if (implicitWildcards.length > 0) {\n const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey}.${e.targetProperty} (${e.functionName ?? '<inline fn>'})`);\n logger.warn(\n 'PropertyDerivation: custom functions without explicit dependsOn detected. ' +\n `These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +\n 'Consider specifying explicit dependsOn arrays for better performance.',\n derivationDescs,\n );\n }\n}\n","import { computed, inject, Signal } from '@angular/core';\nimport { FieldTree } from '@angular/forms/signals';\nimport { DerivationLogConfig, DynamicFormLogger, EXTERNAL_DATA, FieldDef } from '@ng-forge/dynamic-forms/internal';\nimport { DERIVATION_LOG_CONFIG } from '../../providers/features/logger/with-logger-config';\nimport { FormStateManager } from '../../state/form-state-manager';\nimport { PROPERTY_OVERRIDE_STORE } from '../property-derivation/property-override-store';\nimport { createDerivationLogger } from './derivation-logger.service';\nimport { createDerivationOrchestrator, DerivationOrchestratorConfig } from './derivation-orchestrator';\n\n/**\n * Lazy entry point for the derivation engine. This module is the dynamic-import\n * chunk boundary: it statically imports the heavy {@link createDerivationOrchestrator}\n * (and the value/property/HTTP/async stream factories it pulls), so the whole\n * engine only loads when a config actually declares a derivation.\n *\n * Builds the orchestrator config from the form-scoped DI services and constructs\n * the orchestrator. Must be called within the form's injection context (the\n * orchestrator reads `inject(DestroyRef)`/`inject(FORM_OPTIONS)` etc. in its field\n * initializers). The instance is retained by the reactive stream subscriptions it\n * wires (tied to the component `DestroyRef`), so the return value is intentionally\n * discarded.\n *\n * @internal\n */\nexport function bootstrapDerivationOrchestrator(): void {\n const stateManager = inject(FormStateManager);\n const logger = inject(DynamicFormLogger);\n const logConfig = inject<DerivationLogConfig>(DERIVATION_LOG_CONFIG);\n const externalData = inject(EXTERNAL_DATA);\n const propertyStore = inject(PROPERTY_OVERRIDE_STORE);\n\n // FormStateManager is injected without type parameters, so its generic defaults\n // to Record<string, unknown>. The casts widen the type to match\n // DerivationOrchestratorConfig (which uses unknown) — safe because the\n // orchestrator only reads values.\n const config: DerivationOrchestratorConfig = {\n schemaFields: computed(() => stateManager.formSetup()?.schemaFields as FieldDef<unknown>[] | undefined),\n formValue: stateManager.formValue as Signal<Record<string, unknown>>,\n form: computed(() => stateManager.form() as unknown as FieldTree<unknown>),\n derivationLogger: computed(() => createDerivationLogger(logConfig, logger)),\n propertyStore,\n externalData,\n };\n\n createDerivationOrchestrator(config);\n}\n"]}