From c2c289094f0aa2a2bf9eeb1ab6ec088327fc6b4e Mon Sep 17 00:00:00 2001 From: Peter Goldberg Date: Thu, 14 May 2026 10:20:51 +0100 Subject: [PATCH] fix: time-based resolution loses publishedAt on fast path (#11618) --- .../preserve-published-at-in-fast-path.md | 9 +++++++ .../test/install/minimumReleaseAge.ts | 25 +++++++++++++++++++ .../deps-resolver/src/resolveDependencies.ts | 1 + .../package-requester/src/packageRequester.ts | 1 + resolving/npm-resolver/src/index.ts | 13 ++++++++-- resolving/resolver-base/src/index.ts | 1 + store/controller-types/src/index.ts | 1 + 7 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 .changeset/preserve-published-at-in-fast-path.md diff --git a/.changeset/preserve-published-at-in-fast-path.md b/.changeset/preserve-published-at-in-fast-path.md new file mode 100644 index 0000000000..480e5db2cc --- /dev/null +++ b/.changeset/preserve-published-at-in-fast-path.md @@ -0,0 +1,9 @@ +--- +"@pnpm/resolving.npm-resolver": patch +"@pnpm/installing.deps-resolver": patch +"@pnpm/installing.package-requester": patch +"@pnpm/store.controller-types": patch +"pnpm": patch +--- + +Fix `minimumReleaseAge` / `resolutionMode: time-based` installs failing on lockfiles whose `time:` block is missing entries. The npm-resolver's peek-from-store fast path now surfaces `publishedAt` from the lockfile rather than discarding it, and falls through to a registry metadata fetch when the time-based cutoff can't be computed from the data on hand. diff --git a/installing/deps-installer/test/install/minimumReleaseAge.ts b/installing/deps-installer/test/install/minimumReleaseAge.ts index 04c8ea14f7..ab071acd8e 100644 --- a/installing/deps-installer/test/install/minimumReleaseAge.ts +++ b/installing/deps-installer/test/install/minimumReleaseAge.ts @@ -1,5 +1,6 @@ import { expect, test } from '@jest/globals' import { addDependenciesToPackage } from '@pnpm/installing.deps-installer' +import { readWantedLockfile, writeWantedLockfile } from '@pnpm/lockfile.fs' import { prepareEmpty } from '@pnpm/prepare' import { testDefaults } from '../utils/index.js' @@ -88,6 +89,30 @@ test('minimumReleaseAge throws when no mature version satisfies the range and st }).rejects.toThrow(/does not meet the minimumReleaseAge constraint/) }) +test('time-based resolution repopulates missing lockfile time entries on re-install', async () => { + // Regression test: when the npm-resolver fast path (peekManifestFromStore) is + // taken on a re-install, it must surface publishedAt from the lockfile rather + // than returning undefined — otherwise lockfiles whose `time:` block is missing + // entries can never recover them, which breaks downstream time-based filtering + // for packages with version-pinned optional/platform deps. + prepareEmpty() + const opts = testDefaults({ minimumReleaseAge: 1, resolutionMode: 'time-based' }) + + const { updatedManifest } = await addDependenciesToPackage({}, ['is-positive@1.0.0'], opts) + + const lockfileAfterFirstInstall = (await readWantedLockfile('.', { ignoreIncompatible: false }))! + expect(Object.keys(lockfileAfterFirstInstall.time ?? {}).length).toBeGreaterThan(0) + + // Simulate a lockfile whose time entries were dropped (e.g. produced by an + // older pnpm, or hand-edited). + await writeWantedLockfile('.', { ...lockfileAfterFirstInstall, time: {} }) + + await addDependenciesToPackage(updatedManifest, ['is-positive@1.0.0'], opts) + + const lockfileAfterReinstall = (await readWantedLockfile('.', { ignoreIncompatible: false }))! + expect(lockfileAfterReinstall.time).toEqual(lockfileAfterFirstInstall.time) +}) + test('throws error when semver range is used in minimumReleaseAgeExclude', async () => { prepareEmpty() diff --git a/installing/deps-resolver/src/resolveDependencies.ts b/installing/deps-resolver/src/resolveDependencies.ts index 1744dcbdcd..1142afe759 100644 --- a/installing/deps-resolver/src/resolveDependencies.ts +++ b/installing/deps-resolver/src/resolveDependencies.ts @@ -1332,6 +1332,7 @@ async function resolveDependency ( name: currentPkg.name, resolution: currentPkg.resolution, version: currentPkg.version, + publishedAt: currentPkg.pkgId ? ctx.wantedLockfile.time?.[currentPkg.pkgId] : undefined, } : undefined, expectedPkg: currentPkg, diff --git a/installing/package-requester/src/packageRequester.ts b/installing/package-requester/src/packageRequester.ts index c7c9d78d33..97b0e118d2 100644 --- a/installing/package-requester/src/packageRequester.ts +++ b/installing/package-requester/src/packageRequester.ts @@ -181,6 +181,7 @@ async function resolveAndFetch ( name: options.currentPkg.name, version: options.currentPkg.version, resolution: options.currentPkg.resolution, + publishedAt: options.currentPkg.publishedAt, } : undefined, }), { priority: options.downloadPriority }) diff --git a/resolving/npm-resolver/src/index.ts b/resolving/npm-resolver/src/index.ts index e07adad265..e57e1b71c7 100644 --- a/resolving/npm-resolver/src/index.ts +++ b/resolving/npm-resolver/src/index.ts @@ -319,6 +319,7 @@ async function resolveNpm ( name?: string version?: string resolution: TarballResolution + publishedAt?: string } } ): Promise { @@ -353,7 +354,15 @@ async function resolveNpm ( // Fast path: if we have a current resolution with integrity, try to peek the manifest from the store. // This avoids the expensive metadata fetch from the registry. // We do this AFTER ensuring the spec is valid for this resolver to avoids hijacking other resolvers. - if (ctx.peekManifestFromStore && opts.currentPkg?.resolution && !opts.update) { + // If publishedBy is set (resolutionMode=time-based or minimumReleaseAge is configured), we only take + // the fast path when publishedAt is already known from the lockfile's `time:` block; otherwise we + // fall through to a registry fetch so the cutoff isn't computed from missing data. + if ( + ctx.peekManifestFromStore && + opts.currentPkg?.resolution && + !opts.update && + (opts.publishedBy == null || opts.currentPkg.publishedAt != null) + ) { const currentResolution = opts.currentPkg.resolution // Only use this optimization for tarball resolutions with integrity (npm packages) if ('tarball' in currentResolution && currentResolution.integrity) { @@ -373,7 +382,7 @@ async function resolveNpm ( manifest, resolution: currentResolution as TarballResolution, resolvedVia: 'npm-registry', - publishedAt: undefined, // Don't have this without metadata + publishedAt: opts.currentPkg.publishedAt, } } } diff --git a/resolving/resolver-base/src/index.ts b/resolving/resolver-base/src/index.ts index 9a58a30d11..52fd349729 100644 --- a/resolving/resolver-base/src/index.ts +++ b/resolving/resolver-base/src/index.ts @@ -213,6 +213,7 @@ export interface ResolveOptions { name?: string version?: string resolution: Resolution + publishedAt?: string } } diff --git a/store/controller-types/src/index.ts b/store/controller-types/src/index.ts index 29aeaa34df..8bc5a9bfd7 100644 --- a/store/controller-types/src/index.ts +++ b/store/controller-types/src/index.ts @@ -101,6 +101,7 @@ export interface RequestPackageOptions { name?: string resolution?: Resolution version?: string + publishedAt?: string } /** * Expected package is the package name and version that are found in the lockfile.