import { CollapsedDelta, Lossless, LosslessViewOne } from "../../lossless"; import { Lossy } from '../../lossy'; import { DomainEntityID, PropertyID, PropertyTypes } from "../../../core/types"; import { ResolverPlugin, DependencyStates } from "./plugin"; import { EntityRecord } from "@src/core/entity"; import Debug from 'debug'; const debug = Debug('rz:resolver'); const debugState = Debug('rz:resolver:state'); /** * The state of a property for a single entity */ type EntityState = Record; /** * Entities with their plugin states */ type Accumulator = Record; /** * Entities with their resolved properties */ type Result = Record; /** * Type to map property IDs to their plugin types * * @template T - The type of the plugin's state * @template D - The type of the plugin's dependencies (defaults to PropertyID) */ type PluginMap = { [P in PropertyID]: ResolverPlugin; }; /** * Resolver plugins are able to define (virtual) properties on entities. * Plugins can depend on other plugins, which will be resolved in topological order * each time the view is updated. (The view is updated when the hyperview ingests a delta * pertaining to an entity.) */ export class CustomResolver extends Lossy { readonly config: PluginMap; readonly executionOrder: PropertyID[] = []; readonly dependencyGraph: Map> = new Map(); /** * Creates a new CustomResolver instance * @param lossless - The Lossless instance to use for delta tracking * @param config - A mapping of property IDs to their resolver plugins */ constructor( lossless: Lossless, config: PluginMap ) { super(lossless); this.config = config; this.buildDependencyGraph(); this.executionOrder = this.calculateExecutionOrder(); } /** * If you know the key by which a plugin is registered, this will return the base name * @param propertyId The key by which a plugin is registered * @returns The base name of the plugin */ pluginBasenameFromKey(propertyId: PropertyID): PropertyID { return this.config[propertyId]?.name || propertyId; } /** * If you know the base name of a plugin, this will return the key by which it is registered * @param alias The alias of the plugin * @returns The key by which it is registered */ pluginKeyFromBasename(alias: PropertyID): PropertyID { const entry = Object.entries(this.config).find(([_, plugin]) => plugin.name === alias); if (!entry) return alias; return entry[0]; } /** * Build the dependency graph for all plugins. * We'll use the basenames of the plugins in the graph. */ private buildDependencyGraph(): void { // Initialize the graph with all plugins Object.keys(this.config).forEach(propertyId => { const pluginId = this.pluginBasenameFromKey(propertyId); this.dependencyGraph.set(pluginId, new Set()); }); // Add edges based on dependencies Object.entries(this.config).forEach(([propertyId, plugin]) => { const pluginId = this.pluginBasenameFromKey(propertyId); const deps = plugin.dependencies || []; deps.forEach((depId: string) => { // This dependency may have an alias in our current config const depKey = this.pluginKeyFromBasename(depId); if (!this.config[depKey]) { debug(`Config: ${JSON.stringify(this.config)}`) throw new Error(`Dependency ${depId} not found for plugin ${propertyId}`); } this.dependencyGraph.get(depId)?.add(pluginId); }); }); debug(`Config: ${JSON.stringify(this.config)}`); debug(`Dependency graph: ${JSON.stringify(this.dependencyGraph)}`); } /** * Calculate the execution order of properties based on their dependencies * using Kahn's algorithm for topological sorting */ private calculateExecutionOrder(): PropertyID[] { const order: PropertyID[] = []; const inDegree = new Map(); // Initialize in-degree count for all nodes this.dependencyGraph.forEach((_, node) => { inDegree.set(node, 0); }); // Calculate in-degree for each node this.dependencyGraph.forEach((deps) => { deps.forEach(dep => { inDegree.set(dep, (inDegree.get(dep) || 0) + 1); }); }); // Queue for nodes with no incoming edges const queue: PropertyID[] = []; inDegree.forEach((degree, node) => { if (degree === 0) { queue.push(node); } }); // Process nodes in topological order while (queue.length > 0) { const node = queue.shift()!; order.push(node); // Decrease in-degree for all neighbors this.dependencyGraph.get(node)?.forEach(neighbor => { const newDegree = (inDegree.get(neighbor) || 0) - 1; inDegree.set(neighbor, newDegree); // If in-degree becomes zero, add to queue if (newDegree === 0) { queue.push(neighbor); } }); } // Check for cycles if (order.length !== this.dependencyGraph.size) { throw new Error('Circular dependency detected in plugin dependencies'); } return order; } /** * Get the resolved states of all dependencies for a plugin * @param entityState The state of the entity * @param dependencies The dependencies to resolve * */ private getDependencyStates( entityState: EntityState, plugin: ResolverPlugin ): DependencyStates { const dependencyStates = {} as DependencyStates; for (const depId of plugin.dependencies || []) { const depKey = this.pluginKeyFromBasename(depId); const depPlugin = this.config[depKey]; const depValue = entityState[depKey]; if (depValue) { // Resolve the dependency's dependencies first const depDependencies = this.getDependencyStates( entityState, depPlugin ); // Resolve the dependency's state dependencyStates[depId] = depPlugin.resolve( depValue, depDependencies ); } } return dependencyStates; } /** * Update the state with new deltas from the view */ reducer(acc: Accumulator, {id: entityId, propertyDeltas}: LosslessViewOne): Accumulator { debug(`Processing deltas for entity: ${entityId}`); debug('Property deltas:', JSON.stringify(propertyDeltas)); if (!acc[entityId]) { acc[entityId] = {}; } const entityState = acc[entityId]; // Now let's go through each plugin in order. for (const pluginId of this.executionOrder) { const pluginKey = this.pluginKeyFromBasename(pluginId); const plugin = this.config[pluginKey]; if (!plugin) throw new Error(`Plugin for property ${pluginId} not found`); debug(`Processing plugin: ${pluginId} (key: ${pluginKey})`); // We need to resolve dependencies, including entity properties that are not plugins. const dependencies = this.getDependencyStates(entityState, plugin); // Initialize the plugin if it hasn't been initialized yet const pluginState = entityState[pluginKey] ?? plugin.initialize(dependencies); const deltaPropertyValues : Record = {}; let propertyValue : PropertyTypes | undefined; let updateDelta : CollapsedDelta | undefined; for (const [propertyId, deltas] of Object.entries(propertyDeltas)) { for (const delta of deltas) { // Iterate through the pointers; throw an error if a duplicate key is found for (const pointer of delta.pointers) { if (deltaPropertyValues[propertyId]) { // It's possible that there are multiple deltas in this set with the same property ID. // That can only happen if they are part of a transaction. Otherwise this function is // only called once per delta, per entity affected. throw new Error(`Duplicate property ID ${propertyId} found in delta ${delta.id}`); } deltaPropertyValues[propertyId] = pointer[propertyId]; // If there's an updated entity property matching the plugin key, // pass it to the plugin update as the new property value. if (propertyId === pluginKey) { propertyValue = pointer[propertyId]; updateDelta = delta; } } } } // Update the plugin state with the new delta entityState[pluginKey] = plugin.update(pluginState, propertyValue, updateDelta, dependencies); debugState(`Updated entity state for ${entityId}:`, JSON.stringify(entityState[pluginKey])); } return acc; } resolver(acc: Accumulator, entityIds: DomainEntityID[]) { const result: Result = {}; debug('Initial accumulator state:', JSON.stringify(acc)); for (const entityId in acc) { if (!entityIds.includes(entityId)) continue; result[entityId] = { id: entityId, properties: {} }; for (const propertyId of this.executionOrder) { const pluginKey = this.pluginKeyFromBasename(propertyId); const plugin = this.config[pluginKey]; if (!plugin) throw new Error(`Plugin for property ${propertyId} not found`); debug(`Processing property: ${propertyId} (key: ${pluginKey})`); const dependencies = this.getDependencyStates(acc[entityId], plugin); debug(`Dependencies for ${propertyId}:`, JSON.stringify(dependencies)); const state = acc[entityId][pluginKey] || plugin.initialize(dependencies); debug(`State for ${propertyId}:`, JSON.stringify(state)); const resolvedValue = plugin.resolve(state, dependencies); if (resolvedValue === undefined) throw new Error(`Resolved value for property ${propertyId} is undefined`) debug(`Resolved value for ${propertyId}:`, resolvedValue); result[entityId].properties[pluginKey] = resolvedValue; } } return result; } }