export type DependencyRelation = 'link' | 'expression' | 'topo-ref' | 'container' | 'view' export type DependencyEdge = { sourceId: string targetId: string relation: DependencyRelation propertyName?: string reference?: string } export type RecomputePlan = { affected: string[] order: string[] levels: string[][] cycles: string[][] } export type RecomputeState = 'up-to-date' | 'touched' | 'recomputing' | 'error' | 'upstream-failed' export type RecomputeSnapshot = { generation: number status: 'idle' | 'recomputing' | 'completed' | 'failed' objectStates: Record dirtyObjects: string[] order: string[] errors: Array<{ objectId: string; code: string; message: string }> } export class DependencyGraph { private readonly nodes = new Set() private readonly outgoing = new Map>() private readonly incoming = new Map>() private readonly edgeList: DependencyEdge[] = [] constructor(edges: DependencyEdge[] = [], nodeIds: string[] = []) { nodeIds.forEach((nodeId) => this.addNode(nodeId)) edges.forEach((edge) => this.addEdge(edge)) } addNode(nodeId: string) { this.nodes.add(nodeId) if (!this.outgoing.has(nodeId)) this.outgoing.set(nodeId, new Set()) if (!this.incoming.has(nodeId)) this.incoming.set(nodeId, new Set()) } addEdge(edge: DependencyEdge) { if (!edge.sourceId || !edge.targetId) throw new RangeError('Dependency edge requires sourceId and targetId.') this.addNode(edge.sourceId) this.addNode(edge.targetId) if (this.edgeList.some((candidate) => candidate.sourceId === edge.sourceId && candidate.targetId === edge.targetId && candidate.relation === edge.relation && candidate.propertyName === edge.propertyName)) return this.edgeList.push({ ...edge }) this.outgoing.get(edge.sourceId)?.add(edge.targetId) this.incoming.get(edge.targetId)?.add(edge.sourceId) } removeEdgesForProperty(sourceId: string, propertyName: string) { const retained = this.edgeList.filter((edge) => edge.sourceId !== sourceId || edge.propertyName !== propertyName) this.edgeList.length = 0 retained.forEach((edge) => this.edgeList.push(edge)) this.rebuildIndexes() } edges(): DependencyEdge[] { return this.edgeList.map((edge) => ({ ...edge })) } dependenciesOf(sourceId: string): string[] { return [...(this.outgoing.get(sourceId) ?? [])] } dependentsOf(targetId: string): string[] { return [...(this.incoming.get(targetId) ?? [])] } plan(dirtyIds: Iterable): RecomputePlan { const affected = new Set() const visitDependents = (nodeId: string) => { if (affected.has(nodeId)) return affected.add(nodeId) for (const dependent of this.dependentsOf(nodeId)) visitDependents(dependent) } for (const dirtyId of dirtyIds) if (this.nodes.has(dirtyId)) visitDependents(dirtyId) const cycles = this.findCycles(affected) const cyclic = new Set(cycles.flat()) const order: string[] = [] const visited = new Set() const visitDependencies = (nodeId: string) => { if (visited.has(nodeId)) return visited.add(nodeId) for (const dependency of this.dependenciesOf(nodeId)) if (affected.has(dependency) && !cyclic.has(dependency)) visitDependencies(dependency) if (!cyclic.has(nodeId)) order.push(nodeId) } for (const nodeId of affected) visitDependencies(nodeId) const levelByNode = new Map() for (const nodeId of order) { const level = Math.max(0, ...this.dependenciesOf(nodeId).filter((dependency) => affected.has(dependency)).map((dependency) => (levelByNode.get(dependency) ?? 0) + 1)) levelByNode.set(nodeId, level) } const levels: string[][] = [] for (const nodeId of order) { const level = levelByNode.get(nodeId) ?? 0; (levels[level] ??= []).push(nodeId) } return { affected: [...affected], order, levels, cycles } } findCycles(scope: Set = this.nodes): string[][] { const cycles: string[][] = [] const indexByNode = new Map() const lowLinkByNode = new Map() const stack: string[] = [] const onStack = new Set() let index = 0 const strongConnect = (nodeId: string) => { indexByNode.set(nodeId, index) lowLinkByNode.set(nodeId, index) index += 1 stack.push(nodeId) onStack.add(nodeId) for (const dependency of this.dependenciesOf(nodeId)) { if (!scope.has(dependency)) continue if (!indexByNode.has(dependency)) { strongConnect(dependency) lowLinkByNode.set(nodeId, Math.min(lowLinkByNode.get(nodeId) as number, lowLinkByNode.get(dependency) as number)) } else if (onStack.has(dependency)) { lowLinkByNode.set(nodeId, Math.min(lowLinkByNode.get(nodeId) as number, indexByNode.get(dependency) as number)) } } if (lowLinkByNode.get(nodeId) !== indexByNode.get(nodeId)) return const component: string[] = [] let candidate = '' do { candidate = stack.pop() as string onStack.delete(candidate) component.push(candidate) } while (candidate !== nodeId) if (component.length > 1 || this.dependenciesOf(nodeId).includes(nodeId)) { const members = new Set(component) cycles.push([...scope].filter((candidate) => members.has(candidate))) } } for (const nodeId of scope) if (!indexByNode.has(nodeId)) strongConnect(nodeId) return cycles } private rebuildIndexes() { this.outgoing.clear() this.incoming.clear() for (const nodeId of this.nodes) { this.outgoing.set(nodeId, new Set()); this.incoming.set(nodeId, new Set()) } for (const edge of this.edgeList) { this.outgoing.get(edge.sourceId)?.add(edge.targetId); this.incoming.get(edge.targetId)?.add(edge.sourceId) } } } export const createRecomputeSnapshot = (objectIds: string[], generation = 0): RecomputeSnapshot => ({ generation, status: 'idle', objectStates: Object.fromEntries(objectIds.map((objectId) => [objectId, 'up-to-date'])), dirtyObjects: [], order: [], errors: [], })