diff --git a/src/typeschema/register.ts b/src/typeschema/register.ts index 141dfcca..cf45d674 100644 --- a/src/typeschema/register.ts +++ b/src/typeschema/register.ts @@ -93,6 +93,7 @@ const mkPackageAwareResolver = async ( if (acc[pkgId]) return acc[pkgId]; const index = mkEmptyPkgIndex(pkg); + acc[pkgId] = index; for (const resource of await manager.search({ package: pkg })) { const rawUrl = resource.url; if (!rawUrl) continue; @@ -115,7 +116,6 @@ const mkPackageAwareResolver = async ( resolutionOptions.sort((a, b) => a.deep - b.deep); } - acc[pkgId] = index; return index; }; diff --git a/test/unit/typeschema/register.test.ts b/test/unit/typeschema/register.test.ts index 22108515..fc76adb0 100644 --- a/test/unit/typeschema/register.test.ts +++ b/test/unit/typeschema/register.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "bun:test"; import type { FHIRSchema } from "@atomic-ehr/fhirschema"; import { type CanonicalUrl, enrichFHIRSchema, type Name } from "@root/typeschema/types"; -import { mergeFsElementProps, registerFromPackageMetas, resolveFsElementGenealogy } from "@typeschema/register"; +import { + mergeFsElementProps, + registerFromManager, + registerFromPackageMetas, + resolveFsElementGenealogy, +} from "@typeschema/register"; type PFS = Partial; @@ -150,3 +155,32 @@ describe("Register tests", async () => { }); }); }); + +describe("cyclic package dependencies", () => { + it("registerFromManager terminates on a dependency cycle", async () => { + // a ↔ b mutually depend (like hl7.terminology.r5 ↔ hl7.fhir.uv.extensions.r5). + // Without memoizing each package before recursing into its deps, the walk recurses + // forever; each unique package should be visited exactly once. The search-call cap + // makes a regression fail fast (red) instead of slowly exhausting memory. + const deps: Record = { a: ["b"], b: ["a"] }; + let searchCalls = 0; + const manager = { + packages: async () => Object.keys(deps).map((name) => ({ name, version: "1.0.0" })), + packageJson: async (name: string) => ({ + name, + version: "1.0.0", + dependencies: Object.fromEntries((deps[name] ?? []).map((d) => [d, "1.0.0"])), + }), + search: async () => { + if (++searchCalls > 10) throw new Error("dependency walk did not terminate on cycle"); + return []; + }, + } as unknown as Parameters[0]; + + const reg = await registerFromManager(manager, { focusedPackages: [{ name: "a", version: "1.0.0" }] }); + + expect(searchCalls).toBe(2); // each package scanned exactly once + expect(reg.resolver["a#1.0.0"]).toBeDefined(); + expect(reg.resolver["b#1.0.0"]).toBeDefined(); + }); +});