154 lines
6.0 KiB
TypeScript
154 lines
6.0 KiB
TypeScript
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<string, RecomputeState>
|
|
dirtyObjects: string[]
|
|
order: string[]
|
|
errors: Array<{ objectId: string; code: string; message: string }>
|
|
}
|
|
|
|
export class DependencyGraph {
|
|
private readonly nodes = new Set<string>()
|
|
private readonly outgoing = new Map<string, Set<string>>()
|
|
private readonly incoming = new Map<string, Set<string>>()
|
|
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<string>): RecomputePlan {
|
|
const affected = new Set<string>()
|
|
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<string>()
|
|
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<string, number>()
|
|
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<string> = this.nodes): string[][] {
|
|
const cycles: string[][] = []
|
|
const indexByNode = new Map<string, number>()
|
|
const lowLinkByNode = new Map<string, number>()
|
|
const stack: string[] = []
|
|
const onStack = new Set<string>()
|
|
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: [],
|
|
})
|