Lentil Hoffman d0941f417e
refactor: optimize lossless view and improve code quality
- Optimize pointer handling and entity reference tracking in Lossless view
- Improve type safety with proper TypeScript types
- Add debug logging for better troubleshooting
- Clean up imports and unused variables
- Update test cases for DeltaBuilder
2025-06-25 11:18:52 -05:00

288 lines
9.9 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:resolver');
const debugState = Debug('rz: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();
}
/**
* 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<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 entityState The state of the entity
* @param dependencies The dependencies to resolve
*
*/
private getDependencyStates(
entityState: EntityState,
plugin: ResolverPlugin<unknown, string>
): 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<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.
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;
}
}