improve dependency cache performance

This commit is contained in:
callumalpass 2026-06-08 22:58:10 +10:00
parent 6f36847464
commit 4d3e351092
4 changed files with 618 additions and 72 deletions

View file

@ -44,6 +44,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l
- (#1737) Clarified that changing the task tag does not automatically update existing Base view filters. Thanks to @goldorak00 for reporting this and @Domcoppinger for the first-time setup feedback.
- (#2005) Excluded archived task notes and archived project-note references from Task & Project Statistics. Thanks to @Xiarno for suggesting this.
- Added a local review-type lint check that catches dependency-resolution warnings from Obsidian's online review before submission.
- Improved dependency and project relationship cache updates so ordinary note edits avoid unnecessary relationship recalculation, while blocked and blocking state stays current when dependency status changes.
## Fixed

View file

@ -33,10 +33,14 @@ export class DependencyCache extends Events {
// Dependency indexes
private dependencySources: Map<string, Set<string>> = new Map(); // task path -> blocking task paths
private dependencyTargets: Map<string, Set<string>> = new Map(); // task path -> tasks blocked by this task
private activeDependencySources: Map<string, Set<string>> = new Map(); // task path -> incomplete blocking task paths
private activeDependencyTargets: Map<string, Set<string>> = new Map(); // task path -> tasks actively blocked by this task
// Project references index
private projectReferences: Map<string, Set<string>> = new Map(); // project path -> Set<task paths that reference it>
private projectReferenceSources: Map<string, Set<string>> = new Map(); // task path -> Set<project paths it references>
private relationshipFingerprints: Map<string, string> = new Map(); // task path -> normalized dependency/project fields
private completedStatusByPath: Map<string, boolean> = new Map(); // file path -> completion state for status-aware dependency lookups
// Initialization state
private initialized = false;
@ -142,15 +146,27 @@ export class DependencyCache extends Events {
return;
}
const metadata = this.app.metadataCache.getFileCache(file);
if (!metadata?.frontmatter) {
this.clearForwardDependencies(file.path);
const frontmatter = this.getFrontmatterFromCache(cache) ?? this.getFrontmatterForFile(file);
this.updateCompletionState(file.path, frontmatter);
if (!frontmatter) {
if (this.hasForwardRelationships(file.path)) {
this.clearForwardDependencies(file.path);
}
this.triggerIfFileRelationshipsChanged(file.path, before);
return;
}
if (!this.isTaskFileCallback(metadata.frontmatter)) {
this.clearForwardDependencies(file.path);
if (!this.isTaskFileCallback(frontmatter)) {
if (this.hasForwardRelationships(file.path)) {
this.clearForwardDependencies(file.path);
}
this.triggerIfFileRelationshipsChanged(file.path, before);
return;
}
const nextFingerprint = this.buildRelationshipFingerprint(frontmatter);
if (this.relationshipFingerprints.get(file.path) === nextFingerprint) {
this.triggerIfFileRelationshipsChanged(file.path, before);
return;
}
@ -159,7 +175,7 @@ export class DependencyCache extends Events {
// Only clear the forward dependencies (tasks this task depends on)
// Keep reverse dependencies intact - they'll be updated when other tasks change
this.clearForwardDependencies(file.path);
this.indexTaskFile(file.path, metadata.frontmatter);
this.indexTaskFile(file.path, frontmatter);
this.triggerIfFileRelationshipsChanged(file.path, before);
}
@ -171,7 +187,7 @@ export class DependencyCache extends Events {
private getFileRelationshipSignature(path: string): string {
const blockingTasks = this.sortedSetValues(this.dependencySources.get(path));
const blockedTasks = this.sortedSetValues(this.dependencyTargets.get(path));
const blockedTasks = this.sortedSetValues(this.activeDependencyTargets.get(path));
const referencedProjects = this.sortedSetValues(this.projectReferenceSources.get(path));
const projectTasks = this.sortedSetValues(this.projectReferences.get(path));
@ -200,22 +216,36 @@ export class DependencyCache extends Events {
*/
private handleFileRenamed(file: TFile, oldPath: string): void {
// Get metadata for new path
const metadata = this.app.metadataCache.getFileCache(file);
const frontmatter = this.getFrontmatterForFile(file);
// Clear old path
this.clearFileFromIndexes(oldPath);
// Index new path if it's a task
if (
this.isValidFile(file.path) &&
metadata?.frontmatter &&
this.isTaskFileCallback(metadata.frontmatter)
) {
this.indexTaskFile(file.path, metadata.frontmatter);
if (this.isValidFile(file.path) && frontmatter && this.isTaskFileCallback(frontmatter)) {
this.indexTaskFile(file.path, frontmatter);
}
this.trigger(EVENT_DEPENDENCY_CACHE_CHANGED);
}
private getFrontmatterForFile(file: TFile): Record<string, unknown> | null {
const metadata = this.app.metadataCache.getFileCache(file);
return this.getFrontmatterFromCache(metadata);
}
private getFrontmatterFromCache(cache: unknown): Record<string, unknown> | null {
if (!cache || typeof cache !== "object" || !("frontmatter" in cache)) {
return null;
}
const frontmatter = (cache as { frontmatter?: unknown }).frontmatter;
if (!frontmatter || typeof frontmatter !== "object" || Array.isArray(frontmatter)) {
return null;
}
return frontmatter as Record<string, unknown>;
}
/**
* Resolve a project reference string to a file path
*/
@ -242,6 +272,9 @@ export class DependencyCache extends Events {
return;
}
this.relationshipFingerprints.set(path, this.buildRelationshipFingerprint(frontmatter));
this.completedStatusByPath.set(path, this.isCompletedFrontmatter(frontmatter));
const dependenciesField = this.fieldMapper?.toUserField("blockedBy") || "blockedBy";
const projectField = this.fieldMapper?.toUserField("projects") || "project";
@ -255,13 +288,7 @@ export class DependencyCache extends Events {
for (const dep of normalized) {
const resolved = resolveDependencyEntry(this.app, path, dep);
if (resolved?.path && this.isValidFile(resolved.path)) {
blockingTasks.add(resolved.path);
// Add to targets (reverse mapping)
if (!this.dependencyTargets.has(resolved.path)) {
this.dependencyTargets.set(resolved.path, new Set());
}
this.dependencyTargets.get(resolved.path)!.add(path);
this.addDependencyLink(path, resolved.path, blockingTasks);
}
}
@ -296,6 +323,166 @@ export class DependencyCache extends Events {
}
}
private addDependencyLink(
dependentPath: string,
blockingPath: string,
blockingTasks: Set<string>
): void {
blockingTasks.add(blockingPath);
if (!this.dependencyTargets.has(blockingPath)) {
this.dependencyTargets.set(blockingPath, new Set());
}
this.dependencyTargets.get(blockingPath)!.add(dependentPath);
if (!this.isCompletedPath(blockingPath)) {
this.addActiveDependencyLink(dependentPath, blockingPath);
}
}
private addActiveDependencyLink(dependentPath: string, blockingPath: string): void {
if (!this.activeDependencySources.has(dependentPath)) {
this.activeDependencySources.set(dependentPath, new Set());
}
this.activeDependencySources.get(dependentPath)!.add(blockingPath);
if (!this.activeDependencyTargets.has(blockingPath)) {
this.activeDependencyTargets.set(blockingPath, new Set());
}
this.activeDependencyTargets.get(blockingPath)!.add(dependentPath);
}
private removeActiveDependencyLink(dependentPath: string, blockingPath: string): void {
const activeSources = this.activeDependencySources.get(dependentPath);
if (activeSources) {
activeSources.delete(blockingPath);
if (activeSources.size === 0) {
this.activeDependencySources.delete(dependentPath);
}
}
const activeTargets = this.activeDependencyTargets.get(blockingPath);
if (activeTargets) {
activeTargets.delete(dependentPath);
if (activeTargets.size === 0) {
this.activeDependencyTargets.delete(blockingPath);
}
}
}
private rebuildActiveLinksForBlocker(blockingPath: string): void {
const blockedTasks = this.dependencyTargets.get(blockingPath);
this.activeDependencyTargets.delete(blockingPath);
if (!blockedTasks) {
return;
}
for (const dependentPath of blockedTasks) {
const activeSources = this.activeDependencySources.get(dependentPath);
if (activeSources) {
activeSources.delete(blockingPath);
if (activeSources.size === 0) {
this.activeDependencySources.delete(dependentPath);
}
}
}
if (this.isCompletedPath(blockingPath)) {
return;
}
for (const dependentPath of blockedTasks) {
this.addActiveDependencyLink(dependentPath, blockingPath);
}
}
private buildRelationshipFingerprint(frontmatter: Record<string, unknown>): string {
const dependenciesField = this.fieldMapper?.toUserField("blockedBy") || "blockedBy";
const projectField = this.fieldMapper?.toUserField("projects") || "project";
const dependencies = (normalizeDependencyList(frontmatter[dependenciesField]) ?? [])
.map((dependency) => dependency.uid)
.filter((uid) => uid.length > 0)
.sort();
const projects = this.normalizeProjectFingerprintValues(frontmatter[projectField]);
return JSON.stringify({ dependencies, projects });
}
private normalizeProjectFingerprintValues(value: unknown): string[] {
const projects = Array.isArray(value) ? value : value ? [value] : [];
const normalized = new Set<string>();
for (const project of projects) {
if (typeof project !== "string") {
continue;
}
const trimmed = project.trim();
if (trimmed) {
normalized.add(trimmed);
}
}
return Array.from(normalized).sort();
}
private hasForwardRelationships(path: string): boolean {
return (
this.relationshipFingerprints.has(path) ||
this.dependencySources.has(path) ||
this.projectReferenceSources.has(path)
);
}
private updateCompletionState(path: string, frontmatter: Record<string, unknown> | null): void {
const oldCompleted = this.completedStatusByPath.get(path) ?? false;
const newCompleted = frontmatter ? this.isCompletedFrontmatter(frontmatter) : false;
this.completedStatusByPath.set(path, newCompleted);
if (oldCompleted !== newCompleted) {
this.rebuildActiveLinksForBlocker(path);
}
}
private isCompletedPath(path: string): boolean {
const cached = this.completedStatusByPath.get(path);
if (cached !== undefined) {
return cached;
}
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) {
this.completedStatusByPath.set(path, false);
return false;
}
const frontmatter = this.getFrontmatterForFile(file);
const completed = frontmatter ? this.isCompletedFrontmatter(frontmatter) : false;
this.completedStatusByPath.set(path, completed);
return completed;
}
private isCompletedFrontmatter(frontmatter: Record<string, unknown>): boolean {
const statusField = this.fieldMapper?.toUserField("status") || "status";
const status = frontmatter[statusField];
const statusText = this.stringifyStatusValue(status);
return Boolean(statusText && this.statusManager.isCompletedStatus(statusText));
}
private stringifyStatusValue(status: unknown): string | null {
if (
typeof status === "string" ||
typeof status === "number" ||
typeof status === "boolean"
) {
return String(status);
}
return null;
}
/**
* Clear only forward dependencies (tasks this task depends on)
* Used when a task is modified - we rebuild forward deps from frontmatter
@ -314,9 +501,11 @@ export class DependencyCache extends Events {
this.dependencyTargets.delete(blockingTask);
}
}
this.removeActiveDependencyLink(path, blockingTask);
}
this.dependencySources.delete(path);
}
this.activeDependencySources.delete(path);
// Also clear project references since those are stored in this task's frontmatter
const referencedProjects = this.projectReferenceSources.get(path);
@ -332,6 +521,7 @@ export class DependencyCache extends Events {
}
this.projectReferenceSources.delete(path);
}
this.relationshipFingerprints.delete(path);
}
/**
@ -351,9 +541,11 @@ export class DependencyCache extends Events {
this.dependencyTargets.delete(blockingTask);
}
}
this.removeActiveDependencyLink(path, blockingTask);
}
this.dependencySources.delete(path);
}
this.activeDependencySources.delete(path);
// Clear from dependency targets
const blockedTasks = this.dependencyTargets.get(path);
@ -367,9 +559,11 @@ export class DependencyCache extends Events {
this.dependencySources.delete(blockedTask);
}
}
this.removeActiveDependencyLink(blockedTask, path);
}
this.dependencyTargets.delete(path);
}
this.activeDependencyTargets.delete(path);
// Clear project references declared by this file
const referencedProjects = this.projectReferenceSources.get(path);
@ -400,6 +594,8 @@ export class DependencyCache extends Events {
}
this.projectReferences.delete(path);
}
this.relationshipFingerprints.delete(path);
this.completedStatusByPath.delete(path);
}
/**
@ -438,11 +634,7 @@ export class DependencyCache extends Events {
this.buildIndexesSync();
}
if (this.isCompletedTask(taskPath)) {
return [];
}
const blocked = this.dependencyTargets.get(taskPath);
const blocked = this.activeDependencyTargets.get(taskPath);
return blocked ? Array.from(blocked) : [];
}
@ -451,53 +643,10 @@ export class DependencyCache extends Events {
* Only returns true if the task has blocking dependencies that are NOT completed
*/
isTaskBlocked(taskPath: string): boolean {
const blockingPaths = this.getBlockingTaskPaths(taskPath);
if (blockingPaths.length === 0) {
return false;
if (!this.indexesBuilt) {
this.buildIndexesSync();
}
const statusField = this.fieldMapper?.toUserField("status") || "status";
// Check if any blocking task is incomplete
for (const blockingPath of blockingPaths) {
const file = this.app.vault.getAbstractFileByPath(blockingPath);
if (!(file instanceof TFile)) {
// If we can't find the blocking task file, consider it as blocking
// (conservative approach - better to show blocked than hide it)
continue;
}
const metadata = this.app.metadataCache.getFileCache(file);
if (!metadata?.frontmatter) {
// No metadata means we can't determine status, assume blocking
continue;
}
const status = metadata.frontmatter[statusField];
if (!status || !this.statusManager.isCompletedStatus(status)) {
// Found at least one incomplete blocking task
return true;
}
}
// All blocking tasks are completed
return false;
}
private isCompletedTask(taskPath: string): boolean {
const file = this.app.vault.getAbstractFileByPath(taskPath);
if (!(file instanceof TFile)) {
return false;
}
const metadata = this.app.metadataCache.getFileCache(file);
if (!metadata?.frontmatter) {
return false;
}
const statusField = this.fieldMapper?.toUserField("status") || "status";
const status = metadata.frontmatter[statusField];
return Boolean(status && this.statusManager.isCompletedStatus(status));
return (this.activeDependencySources.get(taskPath)?.size ?? 0) > 0;
}
/**
@ -576,8 +725,12 @@ export class DependencyCache extends Events {
private clearIndexes(): void {
this.dependencySources.clear();
this.dependencyTargets.clear();
this.activeDependencySources.clear();
this.activeDependencyTargets.clear();
this.projectReferences.clear();
this.projectReferenceSources.clear();
this.relationshipFingerprints.clear();
this.completedStatusByPath.clear();
}
/**

View file

@ -0,0 +1,223 @@
import { TFile, type App } from "obsidian";
import { FieldMapper } from "../../src/services/FieldMapper";
import { DEFAULT_FIELD_MAPPING, DEFAULT_SETTINGS } from "../../src/settings/defaults";
import { DependencyCache, EVENT_DEPENDENCY_CACHE_CHANGED } from "../../src/utils/DependencyCache";
jest.mock("obsidian");
type MetadataChangedHandler = (file: TFile, data: unknown, cache: unknown) => void;
type MockApp = App & {
__files: Map<string, TFile>;
__metadata: Map<string, { frontmatter: Record<string, unknown> }>;
__metadataChangedHandlers: MetadataChangedHandler[];
__linkResolutionCalls: number;
};
interface LargeGraphFixture {
app: MockApp;
cache: DependencyCache;
blockerPaths: string[];
dependentPaths: string[];
changeHandler: jest.Mock;
}
function createMockApp(): MockApp {
const files = new Map<string, TFile>();
const metadata = new Map<string, { frontmatter: Record<string, unknown> }>();
const metadataChangedHandlers: MetadataChangedHandler[] = [];
const app = {
__files: files,
__metadata: metadata,
__metadataChangedHandlers: metadataChangedHandlers,
__linkResolutionCalls: 0,
vault: {
getMarkdownFiles: jest.fn(() => Array.from(files.values())),
getAbstractFileByPath: jest.fn((path: string) => files.get(path) ?? null),
on: jest.fn(() => ({})),
},
metadataCache: {
getFileCache: jest.fn((file: TFile) => metadata.get(file.path) ?? null),
getFirstLinkpathDest: jest.fn((linkpath: string) => {
app.__linkResolutionCalls++;
return files.get(linkpath) ?? files.get(`${linkpath}.md`) ?? null;
}),
on: jest.fn((eventName: string, handler: MetadataChangedHandler) => {
if (eventName === "changed") {
metadataChangedHandlers.push(handler);
}
return {};
}),
offref: jest.fn(),
},
} as unknown as MockApp;
return app;
}
function createFile(app: MockApp, path: string, frontmatter: Record<string, unknown>): TFile {
const file = new TFile(path);
app.__files.set(path, file);
app.__metadata.set(path, { frontmatter });
return file;
}
function createDependencyCache(app: MockApp): DependencyCache {
return new DependencyCache(
app,
{
...DEFAULT_SETTINGS,
taskIdentificationMethod: "tag",
taskTag: "task",
},
new FieldMapper(DEFAULT_FIELD_MAPPING),
{ isCompletedStatus: jest.fn((status: string) => status === "done") } as never,
(frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags)
);
}
function createLargeGraph(blockerCount = 250, dependentCount = 2500): LargeGraphFixture {
const app = createMockApp();
const blockerPaths: string[] = [];
const dependentPaths: string[] = [];
for (let i = 0; i < blockerCount; i++) {
const path = `Tasks/Blocker-${i}.md`;
blockerPaths.push(path);
createFile(app, path, {
title: `Blocker ${i}`,
status: "open",
tags: ["task"],
});
}
for (let i = 0; i < dependentCount; i++) {
const blockerPath = blockerPaths[i % blockerPaths.length];
const path = `Tasks/Dependent-${i}.md`;
dependentPaths.push(path);
createFile(app, path, {
title: `Dependent ${i}`,
status: "open",
tags: ["task"],
blockedBy: [{ uid: `[[${blockerPath}]]`, reltype: "FINISHTOSTART" }],
projects: [`[[Projects/Project-${i % 20}.md]]`],
});
}
for (let i = 0; i < 20; i++) {
createFile(app, `Projects/Project-${i}.md`, {
title: `Project ${i}`,
});
}
const cache = createDependencyCache(app);
const changeHandler = jest.fn();
cache.on(EVENT_DEPENDENCY_CACHE_CHANGED, changeHandler);
cache.initialize();
return { app, cache, blockerPaths, dependentPaths, changeHandler };
}
function invokeMetadataChanged(app: MockApp, path: string): void {
const file = app.__files.get(path);
if (!file) {
throw new Error(`Missing file ${path}`);
}
const cache = app.__metadata.get(path) ?? null;
for (const handler of app.__metadataChangedHandlers) {
handler(file, null, cache);
}
}
function logMetric(label: string, metric: Record<string, number>): void {
if (process.env.TN_PERF_LOG === "1") {
process.stderr.write(`[dependency-cache-perf] ${label} ${JSON.stringify(metric)}\n`);
}
}
describe("DependencyCache performance smoke", () => {
it("measures large-graph build, edit, and read-heavy paths", async () => {
const { app, cache, blockerPaths, dependentPaths, changeHandler } = createLargeGraph();
let start = performance.now();
await cache.buildIndexes();
const buildDurationMs = performance.now() - start;
const buildLinkResolutionCalls = app.__linkResolutionCalls;
expect(cache.isTaskBlocked(dependentPaths[0])).toBe(true);
expect(cache.getBlockedTaskPaths(blockerPaths[0]).length).toBeGreaterThan(0);
app.__linkResolutionCalls = 0;
changeHandler.mockClear();
start = performance.now();
for (const path of dependentPaths) {
const entry = app.__metadata.get(path);
if (!entry) continue;
app.__metadata.set(path, {
frontmatter: {
...entry.frontmatter,
details: "body-like metadata churn that does not affect relationships",
},
});
invokeMetadataChanged(app, path);
}
const nonRelationshipEditDurationMs = performance.now() - start;
const nonRelationshipEditLinkResolutionCalls = app.__linkResolutionCalls;
const nonRelationshipEditEvents = changeHandler.mock.calls.length;
app.__linkResolutionCalls = 0;
changeHandler.mockClear();
start = performance.now();
const blockerEntry = app.__metadata.get(blockerPaths[0]);
if (!blockerEntry) {
throw new Error("Missing blocker metadata");
}
app.__metadata.set(blockerPaths[0], {
frontmatter: {
...blockerEntry.frontmatter,
status: "done",
},
});
invokeMetadataChanged(app, blockerPaths[0]);
const statusChangeDurationMs = performance.now() - start;
const statusChangeLinkResolutionCalls = app.__linkResolutionCalls;
const statusChangeEvents = changeHandler.mock.calls.length;
expect(cache.isTaskBlocked(dependentPaths[0])).toBe(false);
app.__linkResolutionCalls = 0;
(app.metadataCache.getFileCache as jest.Mock).mockClear();
start = performance.now();
for (const path of dependentPaths) {
cache.isTaskBlocked(path);
}
for (const path of blockerPaths) {
cache.getBlockedTaskPaths(path);
}
const readHeavyDurationMs = performance.now() - start;
const readHeavyLinkResolutionCalls = app.__linkResolutionCalls;
const readHeavyMetadataReads = (app.metadataCache.getFileCache as jest.Mock).mock.calls
.length;
logMetric("large-graph", {
buildDurationMs,
buildLinkResolutionCalls,
nonRelationshipEditDurationMs,
nonRelationshipEditLinkResolutionCalls,
nonRelationshipEditEvents,
statusChangeDurationMs,
statusChangeLinkResolutionCalls,
statusChangeEvents,
readHeavyDurationMs,
readHeavyLinkResolutionCalls,
readHeavyMetadataReads,
});
expect(buildLinkResolutionCalls).toBeGreaterThan(0);
expect(nonRelationshipEditLinkResolutionCalls).toBe(0);
expect(nonRelationshipEditEvents).toBe(0);
expect(statusChangeLinkResolutionCalls).toBe(0);
expect(statusChangeEvents).toBe(1);
expect(readHeavyLinkResolutionCalls).toBe(0);
expect(readHeavyMetadataReads).toBe(0);
});
});

View file

@ -0,0 +1,169 @@
import { TFile, type App } from "obsidian";
import { FieldMapper } from "../../../src/services/FieldMapper";
import { DEFAULT_FIELD_MAPPING, DEFAULT_SETTINGS } from "../../../src/settings/defaults";
import {
DependencyCache,
EVENT_DEPENDENCY_CACHE_CHANGED,
} from "../../../src/utils/DependencyCache";
jest.mock("obsidian");
type MetadataChangedHandler = (file: TFile, data: unknown, cache: unknown) => void;
type MockApp = App & {
__files: Map<string, TFile>;
__metadata: Map<string, { frontmatter: Record<string, unknown> }>;
__metadataChangedHandlers: MetadataChangedHandler[];
__linkResolutionCalls: number;
};
function createMockApp(): MockApp {
const files = new Map<string, TFile>();
const metadata = new Map<string, { frontmatter: Record<string, unknown> }>();
const metadataChangedHandlers: MetadataChangedHandler[] = [];
const app = {
__files: files,
__metadata: metadata,
__metadataChangedHandlers: metadataChangedHandlers,
__linkResolutionCalls: 0,
vault: {
getMarkdownFiles: jest.fn(() => Array.from(files.values())),
getAbstractFileByPath: jest.fn((path: string) => files.get(path) ?? null),
on: jest.fn(() => ({})),
},
metadataCache: {
getFileCache: jest.fn((file: TFile) => metadata.get(file.path) ?? null),
getFirstLinkpathDest: jest.fn((linkpath: string) => {
app.__linkResolutionCalls++;
return files.get(linkpath) ?? files.get(`${linkpath}.md`) ?? null;
}),
on: jest.fn((eventName: string, handler: MetadataChangedHandler) => {
if (eventName === "changed") {
metadataChangedHandlers.push(handler);
}
return {};
}),
offref: jest.fn(),
},
} as unknown as MockApp;
return app;
}
function createFile(app: MockApp, path: string, frontmatter: Record<string, unknown>): TFile {
const file = new TFile(path);
app.__files.set(path, file);
app.__metadata.set(path, { frontmatter });
return file;
}
function createDependencyCache(app: MockApp): DependencyCache {
return new DependencyCache(
app,
{
...DEFAULT_SETTINGS,
taskIdentificationMethod: "tag",
taskTag: "task",
},
new FieldMapper(DEFAULT_FIELD_MAPPING),
{ isCompletedStatus: jest.fn((status: string) => status === "done") } as never,
(frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags)
);
}
function invokeMetadataChanged(app: MockApp, path: string): void {
const file = app.__files.get(path);
if (!file) {
throw new Error(`Missing file ${path}`);
}
const cache = app.__metadata.get(path) ?? null;
for (const handler of app.__metadataChangedHandlers) {
handler(file, null, cache);
}
}
describe("DependencyCache optimized relationship updates", () => {
it("skips link resolution when task metadata changes without relationship changes", async () => {
const app = createMockApp();
createFile(app, "Tasks/Blocker.md", {
title: "Blocker",
status: "open",
tags: ["task"],
});
createFile(app, "Projects/Alpha.md", {
title: "Alpha",
});
createFile(app, "Tasks/Dependent.md", {
title: "Dependent",
status: "open",
tags: ["task"],
blockedBy: [{ uid: "[[Tasks/Blocker.md]]", reltype: "FINISHTOSTART" }],
projects: ["[[Projects/Alpha.md]]"],
});
const cache = createDependencyCache(app);
const changeHandler = jest.fn();
cache.on(EVENT_DEPENDENCY_CACHE_CHANGED, changeHandler);
cache.initialize();
await cache.buildIndexes();
changeHandler.mockClear();
app.__linkResolutionCalls = 0;
app.__metadata.set("Tasks/Dependent.md", {
frontmatter: {
title: "Dependent renamed",
status: "open",
tags: ["task"],
blockedBy: [{ uid: "[[Tasks/Blocker.md]]", reltype: "FINISHTOSTART" }],
projects: ["[[Projects/Alpha.md]]"],
},
});
invokeMetadataChanged(app, "Tasks/Dependent.md");
expect(app.__linkResolutionCalls).toBe(0);
expect(changeHandler).not.toHaveBeenCalled();
expect(cache.isTaskBlocked("Tasks/Dependent.md")).toBe(true);
expect(cache.isFileUsedAsProject("Projects/Alpha.md")).toBe(true);
});
it("updates status-aware active relationships without re-resolving dependency links", async () => {
const app = createMockApp();
createFile(app, "Tasks/Blocker.md", {
title: "Blocker",
status: "open",
tags: ["task"],
});
createFile(app, "Tasks/Dependent.md", {
title: "Dependent",
status: "open",
tags: ["task"],
blockedBy: [{ uid: "[[Tasks/Blocker.md]]", reltype: "FINISHTOSTART" }],
});
const cache = createDependencyCache(app);
const changeHandler = jest.fn();
cache.on(EVENT_DEPENDENCY_CACHE_CHANGED, changeHandler);
cache.initialize();
await cache.buildIndexes();
changeHandler.mockClear();
app.__linkResolutionCalls = 0;
expect(cache.isTaskBlocked("Tasks/Dependent.md")).toBe(true);
expect(cache.getBlockedTaskPaths("Tasks/Blocker.md")).toEqual(["Tasks/Dependent.md"]);
app.__metadata.set("Tasks/Blocker.md", {
frontmatter: {
title: "Blocker",
status: "done",
tags: ["task"],
},
});
invokeMetadataChanged(app, "Tasks/Blocker.md");
expect(app.__linkResolutionCalls).toBe(0);
expect(changeHandler).toHaveBeenCalledTimes(1);
expect(cache.isTaskBlocked("Tasks/Dependent.md")).toBe(false);
expect(cache.getBlockedTaskPaths("Tasks/Blocker.md")).toEqual([]);
});
});