mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-18 05:42:27 -04:00
fix: time-based resolution loses publishedAt on fast path (#11618)
This commit is contained in:
9
.changeset/preserve-published-at-in-fast-path.md
Normal file
9
.changeset/preserve-published-at-in-fast-path.md
Normal file
@@ -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.
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -319,6 +319,7 @@ async function resolveNpm (
|
||||
name?: string
|
||||
version?: string
|
||||
resolution: TarballResolution
|
||||
publishedAt?: string
|
||||
}
|
||||
}
|
||||
): Promise<NpmResolveResult | WorkspaceResolveResult | null> {
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +213,7 @@ export interface ResolveOptions {
|
||||
name?: string
|
||||
version?: string
|
||||
resolution: Resolution
|
||||
publishedAt?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user