339 lines
12 KiB
TypeScript

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:custom-resolver');
const debugState = Debug('rz:custom-resolver:state');
/**
* The state of a property for a single entity
*/
type EntityState = Record<PropertyID, unknown>;
/**
* Entities with their plugin states
*/
type Accumulator = Record<DomainEntityID, EntityState>;
/**
* Entities with their resolved properties
*/
type Result = Record<DomainEntityID, EntityRecord>;
/**
* 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<unknown, PropertyID>;
};
/**
* 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<Accumulator, Result> {
readonly config: PluginMap;
readonly executionOrder: PropertyID[] = [];
readonly dependencyGraph: Map<PropertyID, Set<PropertyID>> = 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();
debug(`Execution order: ${this.executionOrder.join(' -> ')}`);
}
/**
* 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: string): string {
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(name: string): string {
const entry = Object.entries(this.config).find(([_, plugin]) => plugin.name === name);
if (!entry) return name;
return entry[0];
}
private logGraph(): void {
// Log the final dependency graph
const graphLog: Record<string, string[]> = {};
this.dependencyGraph.forEach((deps, plugin) => {
graphLog[plugin] = Array.from(deps);
});
debug(`Dependency graph: ${JSON.stringify(graphLog, null, 2)}`);
}
/**
* Build the dependency graph for all plugins.
* We'll use the basenames of the plugins in the graph.
*/
private buildDependencyGraph(): void {
debug('Building dependency graph...');
// Initialize the graph with all plugins
Object.keys(this.config).forEach(pluginKey => {
this.dependencyGraph.set(pluginKey, new Set());
debug(`Added plugin node: ${pluginKey}`);
});
debug('Processing plugin dependencies...');
// Add edges based on dependencies
Object.entries(this.config).forEach(([pluginKey, plugin]) => {
const pluginId = plugin.name || pluginKey;
const deps = plugin.dependencies || [];
if (deps.length === 0) {
debug(`Plugin ${pluginId} has no dependencies`);
} else {
debug(`Plugin ${pluginId} depends on: ${deps.join(', ')}`);
}
deps.forEach((depId: string) => {
// This dependency may have an alias in our current config
const depKey = this.pluginKeyFromBasename(depId);
debug(`Processing dependency ${depKey} for plugin ${pluginKey}`);
if (!this.config[depKey]) {
// TODO: This could still be a property, not a plugin
const errorMsg = `Dependency ${depKey} not found for plugin ${pluginKey}`;
debug(`Error: ${errorMsg}`);
throw new Error(errorMsg);
}
// Add the dependency edge
const dep = this.dependencyGraph.get(depKey)
if (!dep) {
throw new Error(`Dependency ${depKey} not found in dependency graph`);
}
dep.add(pluginKey);
});
});
debug('Dependency graph construction complete');
debug(`Config: ${JSON.stringify(this.config, null, 2)}`);
this.logGraph();
}
/**
* 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<PropertyID, number>();
// 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 entityPluginStates The state of the entity
* @param dependencies The dependencies to resolve
*
*/
private getDependencyStates(
entityPluginStates: EntityState,
pluginKey: string
): DependencyStates {
const plugin = this.config[pluginKey];
if (!plugin) throw new Error(`Plugin ${pluginKey} not found`);
const dependencyStates: DependencyStates = {};
for (const depKey of this.executionOrder) {
if (depKey === pluginKey) continue;
const depPlugin = this.config[depKey];
if (depPlugin) {
if (!entityPluginStates[depKey]) {
dependencyStates[depKey] = depPlugin.initialize(dependencyStates);
entityPluginStates[depKey] = dependencyStates[depKey];
}
dependencyStates[depKey] = depPlugin.resolve(entityPluginStates[depKey], dependencyStates);
}
}
return dependencyStates;
}
private initializePlugins(acc: Accumulator, entityId: DomainEntityID) {
if (!acc[entityId]) {
acc[entityId] = {};
}
const entityState = acc[entityId];
for (const pluginKey of this.executionOrder) {
const plugin = this.config[pluginKey];
if (!plugin) throw new Error(`Plugin ${pluginKey} not found`);
// We need to resolve dependencies, including entity properties that are not plugins.
const dependencies = this.getDependencyStates(entityState, pluginKey);
debug('Dependencies for', pluginKey, ':', JSON.stringify(dependencies));
// Initialize the plugin if it hasn't been initialized yet
entityState[pluginKey] = entityState[pluginKey] ?? plugin.initialize(dependencies);
}
return { entityState };
}
/**
* 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));
const { entityState } = this.initializePlugins(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})`);
const pluginState = entityState[pluginKey];
const deltaPropertyValues : Record<PropertyID, PropertyTypes> = {};
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.
// TODO: More flexible/robust error handling protocols?
// Some views might be more tolerant of errors than others.
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
const dependencies = this.getDependencyStates(entityState, pluginKey);
entityState[pluginKey] = plugin.applyUpdate(pluginState, propertyValue, updateDelta, dependencies);
debugState(`Updated state for entity ${entityId} plugin ${pluginKey}:`,
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;
this.initializePlugins(acc, entityId);
result[entityId] = {
id: entityId,
properties: {}
};
for (const pluginKey of this.executionOrder) {
const plugin = this.config[pluginKey];
if (!plugin) throw new Error(`Plugin ${pluginKey} not found`);
debug(`Processing property: ${pluginKey}`);
const dependencies = this.getDependencyStates(acc[entityId], pluginKey);
debug(`Dependencies for ${pluginKey}:`, JSON.stringify(dependencies));
const state = acc[entityId][pluginKey] || plugin.initialize(dependencies);
debug(`State for ${pluginKey}:`, JSON.stringify(state));
const resolvedValue = plugin.resolve(state, dependencies);
if (resolvedValue === undefined) throw new Error(`Resolved value for property ${pluginKey} is undefined`)
debug(`Resolved value for ${pluginKey}:`, resolvedValue);
result[entityId].properties[pluginKey] = resolvedValue;
}
}
debug(`Result:`, JSON.stringify(result));
return result;
}
}