diff --git a/apps/server/src/core/base/formula/__tests__/graph.spec.ts b/apps/server/src/core/base/formula/__tests__/graph.spec.ts new file mode 100644 index 000000000..5a9b08304 --- /dev/null +++ b/apps/server/src/core/base/formula/__tests__/graph.spec.ts @@ -0,0 +1,54 @@ +import { BaseFormulaGraph } from "@docmost/base-formula/server"; + +type Prop = { id: string; type: string; typeOptions: any }; + +const mk = (defs: Array<[string, string[]] | [string]>): Prop[] => + defs.map(([id, deps]) => ({ + id, + type: deps ? "formula" : "number", + typeOptions: deps ? { dependencies: deps } : {}, + })); + +describe("BaseFormulaGraph", () => { + it("returns dependents", () => { + const g = new BaseFormulaGraph(mk([ + ["A"], ["B"], ["C", ["A", "B"]], + ])); + expect(g.dependents("A").sort()).toEqual(["C"]); + expect(g.dependents("B").sort()).toEqual(["C"]); + expect(g.dependents("X")).toEqual([]); + }); + + it("produces a topological order", () => { + const g = new BaseFormulaGraph(mk([ + ["A"], ["B", ["A"]], ["C", ["B"]], + ])); + const order = g.evalOrder(); + expect(order.indexOf("B")).toBeLessThan(order.indexOf("C")); + }); + + it("computes transitive affected formulas", () => { + const g = new BaseFormulaGraph(mk([ + ["A"], ["B", ["A"]], ["C", ["B"]], ["D", ["A"]], + ])); + expect(g.affectedFormulas(["A"]).sort()).toEqual(["B", "C", "D"]); + }); + + it("detects a direct cycle", () => { + const props = mk([["A", ["B"]], ["B", ["A"]]]); + const g = new BaseFormulaGraph(props); + expect(g.detectCycle(props[0])).not.toBeNull(); + }); + + it("detects a transitive cycle", () => { + const props = mk([["A", ["B"]], ["B", ["C"]], ["C", ["A"]]]); + const g = new BaseFormulaGraph(props); + expect(g.detectCycle(props[0])).not.toBeNull(); + }); + + it("returns null when there is no cycle", () => { + const props = mk([["A"], ["B", ["A"]]]); + const g = new BaseFormulaGraph(props); + expect(g.detectCycle(props[1])).toBeNull(); + }); +}); diff --git a/packages/base-formula/src/graph.ts b/packages/base-formula/src/graph.ts new file mode 100644 index 000000000..7bcffbd2c --- /dev/null +++ b/packages/base-formula/src/graph.ts @@ -0,0 +1,90 @@ +// packages/base-formula/src/graph.ts + +type PropLike = { id: string; type: string; typeOptions: unknown }; + +export class BaseFormulaGraph { + private readonly direct = new Map(); + private readonly reverse = new Map>(); + + constructor(properties: PropLike[]) { + for (const p of properties) { + if (p.type !== "formula") continue; + const deps: string[] = Array.isArray((p.typeOptions as any)?.dependencies) + ? ((p.typeOptions as any).dependencies as string[]) + : []; + this.direct.set(p.id, deps); + for (const d of deps) { + if (!this.reverse.has(d)) this.reverse.set(d, new Set()); + this.reverse.get(d)!.add(p.id); + } + } + } + + directDeps(propId: string): string[] { return this.direct.get(propId) ?? []; } + + dependents(propId: string): string[] { return Array.from(this.reverse.get(propId) ?? []); } + + affectedFormulas(changedPropIds: string[]): string[] { + const out = new Set(); + const stack = [...changedPropIds]; + while (stack.length) { + const id = stack.pop()!; + for (const d of this.reverse.get(id) ?? []) { + if (!out.has(d)) { out.add(d); stack.push(d); } + } + } + return Array.from(out).sort(); + } + + evalOrder(): string[] { + const order: string[] = []; + const visited = new Set(); + const temp = new Set(); + const visit = (id: string) => { + if (visited.has(id)) return; + if (temp.has(id)) return; + temp.add(id); + for (const d of this.direct.get(id) ?? []) visit(d); + temp.delete(id); + visited.add(id); + order.push(id); + }; + for (const id of this.direct.keys()) visit(id); + return order; + } + + /* + * Returns the cycle path (list of prop IDs) if introducing `newProp` (or + * keeping its current deps) would create one, else null. `newProp` may be + * either a property already registered or a hypothetical replacement; we + * re-read its deps at call time, so pass the candidate object. + */ + detectCycle(newProp: PropLike): string[] | null { + const local = new Map(this.direct); + if (newProp.type === "formula") { + local.set(newProp.id, (newProp.typeOptions as any)?.dependencies ?? []); + } + const WHITE = 0, GRAY = 1, BLACK = 2; + const color = new Map(); + const path: string[] = []; + const dfs = (id: string): string[] | null => { + color.set(id, GRAY); + path.push(id); + for (const d of local.get(id) ?? []) { + const c = color.get(d) ?? WHITE; + if (c === GRAY) { return [...path.slice(path.indexOf(d)), d]; } + if (c === WHITE) { const r = dfs(d); if (r) return r; } + } + path.pop(); + color.set(id, BLACK); + return null; + }; + for (const id of local.keys()) { + if ((color.get(id) ?? WHITE) === WHITE) { + const r = dfs(id); + if (r) return r; + } + } + return null; + } +} diff --git a/packages/base-formula/src/index.client.ts b/packages/base-formula/src/index.client.ts index 6d02d42f1..e9fe47ada 100644 --- a/packages/base-formula/src/index.client.ts +++ b/packages/base-formula/src/index.client.ts @@ -9,3 +9,4 @@ export * from "./resolver"; export * from "./typecheck"; export * from "./format"; export type { FormulaFn } from "./functions/registry"; +export * from "./graph"; diff --git a/packages/base-formula/src/index.server.ts b/packages/base-formula/src/index.server.ts index 4ff549b70..4e5037118 100644 --- a/packages/base-formula/src/index.server.ts +++ b/packages/base-formula/src/index.server.ts @@ -9,3 +9,4 @@ export * from "./typecheck"; export * from "./format"; export { registry } from "./functions/registry"; export type { FormulaFn } from "./functions/registry"; +export * from "./graph";