Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/pm/catalogs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ Bun's lockfile tracks catalog versions, so installs are consistent across enviro

```json bun.lock(excerpt) icon="file-json"
{
"lockfileVersion": 2,
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "react-monorepo",
Expand Down
4 changes: 4 additions & 0 deletions docs/pm/lockfile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@

For more on the format, see [the blog post](https://bun.com/blog/bun-lock-text-lockfile).

#### `lockfileVersion`

The `lockfileVersion` field at the top of `bun.lock` records its format version. A new `bun.lock` is written as `lockfileVersion` 1, which every Bun release since v1.2 can read, and re-saving a `lockfileVersion` 1 or 2 lockfile keeps its version. The exception is [nested or version-scoped overrides](/pm/overrides#limitations): while a lockfile contains those it is written as `lockfileVersion` 3, which requires Bun v1.4 or later, and it goes back to 1 once they are removed.

Check notice on line 59 in docs/pm/lockfile.mdx

View check run for this annotation

Claude / Claude Code Review

BunLockFile type declaration missing lockfileVersion 3

Pre-existing (not introduced by this PR): the public `BunLockFile` type in `packages/bun-types/bun.d.ts:9811` declares `lockfileVersion: 0 | 1 | 2` and `overrides?: Record<string, string>`, so it cannot represent the `lockfileVersion` 3 files this PR now documents at `docs/pm/lockfile.mdx:59` (v3's `overrides` values are objects, not strings). Worth a follow-up to add `| 3` and widen the `overrides` value type; not blocking here since V3 and object-valued overrides both predate this PR and it do
Comment thread
robobun marked this conversation as resolved.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
#### Automatic lockfile migration

When you run `bun install` in a project without a `bun.lock`, Bun automatically migrates existing lockfiles:
Expand Down
82 changes: 25 additions & 57 deletions src/install/lockfile/bun.lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ pub enum Version {
/// - a git `.bun-tag` must be a safe path/checkout component (the same
/// check on a `github` tag is enforced at every version, since its
/// download path has no checkout-time re-validation)
///
/// Same content as v1; only ever preserved, never stamped on a new lockfile.
Comment thread
robobun marked this conversation as resolved.
V2 = 2,

/// `overrides` values may be objects holding scoped rules (parent-scoped or `name@range` targets); stamped while such rules exist and the package walk in `Stringifier::version_to_write` is v2-clean (object rows themselves parse at every version)
Expand Down Expand Up @@ -180,49 +182,25 @@ impl Stringifier {
Self::save_from_binary_inner(lockfile, load_result, options, writer)
}

/// Pick the `lockfileVersion` to stamp. A lockfile loaded from disk keeps
/// the version it already carried — re-saving never silently upgrades an
/// existing `bun.lock` to a newer format. `text_lockfile_version` holds the
/// parsed version when the lockfile was loaded from text, and defaults to
/// `Version::CURRENT` otherwise (a fresh install, or a migration from
/// another lockfile format), the "no version previously" case whose stamp
/// is decided by the walk below.
///
/// The one version that is *not* preserved is v0: v0→v1 was a content-format
/// change (v1 stopped listing a workspace package's dependencies as a
/// trailing object), and the writer only ever emits the v1+ single-element
/// `["name@workspace:path"]` form. Stamping v0 on that output would make the
/// next parse fail ("Missing dependencies object"), so a v0 lockfile is
/// floored to v1 — the lowest version whose content matches what we write.
/// v1→v2, by contrast, only added parse-time strictness on identical
/// content, so v1 is preserved as-is.
///
/// Scoped overrides (parent-scoped or `name@range` rules) are stamped v3, but
/// only after the same walk: a lockfile the walk holds at v1 stays v1 with the
/// override objects written as-is, since the parser reads those at every
/// version while its v2+ integrity check is evaluated against the *reader's*
/// registries — stamping 3 there would make the file config-dependent again.
/// A walk-clean lockfile with scoped rules is stamped v3 whatever version was
/// loaded. Without scoped rules a lockfile keeps its loaded v1/v2, and a fresh
/// or v3-loaded one is walked down to v2, or to v1 on a v2-invariant violation
/// (off-registry npm tarball without a supported integrity, unsafe git
/// `.bun-tag`); that decision must not depend on the writer's `~/.npmrc`.
///
/// Walks the package tree the same way the writer does — only packages that
/// are actually serialized are considered, not every entry in the in-memory
/// `pkg_resolutions` buffer (migration can leave pruned/unreferenced entries
/// there that never reach the written `packages` object).
/// The lowest `lockfileVersion` whose readers understand the content being
/// written: v1 and v2 are the same content and pre-v2 readers reject the v2
/// stamp, so a new lockfile is v1; v3 is stamped only while scoped overrides
/// (which older readers would silently drop) exist.
Comment thread
robobun marked this conversation as resolved.
fn version_to_write(lockfile: &BinaryLockfile) -> Version {
let loaded = lockfile.text_lockfile_version;
let has_scoped = lockfile.overrides.has_scoped();
if !has_scoped && !loaded.at_least(Version::V3) {
return if loaded.at_least(Version::V1) {
loaded
} else {
Version::V1
if !lockfile.overrides.has_scoped() {
return match lockfile.text_lockfile_version {
loaded @ (Version::V1 | Version::V2) => loaded,
// v0: the writer only emits the v1+ workspace entry shape. v3: a
// lockfile that was never loaded sits at `Version::CURRENT`.
Comment thread
robobun marked this conversation as resolved.
Version::V0 | Version::V3 => Version::V1,
};
}

// v3 implies the v2 parse checks, which a reader evaluates against its
// own registry config. Any serialized row that some reader could reject
// holds the file at v1 instead; the override objects parse at every
// version. Walk the tree rather than `pkg_resolutions`, which migration
// can leave holding entries the writer never emits.
Comment thread
robobun marked this conversation as resolved.
let buf = lockfile.buffers.string_bytes.as_slice();
let deps_buf = lockfile.buffers.dependencies.as_slice();
let resolution_buf = lockfile.buffers.resolutions.as_slice();
Expand Down Expand Up @@ -250,29 +228,19 @@ impl Stringifier {
if pkg_metas[i].integrity.tag.is_supported() {
continue;
}
// No supported integrity: only v2-clean if the tarball
// URL is under the *default* registry, the one case the
// writer normalizes to `""` (see the npm URL
// serialization in `save_from_binary_inner`). An empty
// URL never sets the parser's `npm_url_needs_integrity`,
// so that round-trips for any reader. A URL under a
// configured-but-not-default scope is written verbatim,
// and the parser's integrity check is evaluated against
// the *reader's* scope config, so it is not
// config-independent: a writer with a private `@scope`
// registry could stamp v2 on a lockfile a teammate
// without that scope then fails to parse. Stay at v1 for
// those so the file keeps loading everywhere.
// Only a default-registry URL is config-independent: the
// writer serializes it as `""`, which never trips the
// parser's `npm_url_needs_integrity`. A URL under one of
// the writer's own scopes is written verbatim and a reader
// without that scope would reject it.
Comment thread
robobun marked this conversation as resolved.
let url = res.npm().url.slice(buf);
if !url_is_under_registry(url, Npm::Registry::DEFAULT_URL.as_bytes()) {
return Version::V1;
}
}
ResolutionTag::Git => {
// An unsafe git `.bun-tag` is only rejected at v2, so
// staying at v1 keeps it loading. (A `github` tag is
// rejected at every version, so no lockfile version can
// round-trip an unsafe one — nothing to gate here.)
// A `github` tag is rejected at every version, so there is
// nothing to gate for it.
Comment thread
robobun marked this conversation as resolved.
if !crate::repository::is_safe_resolved_tag(
res.repository().resolved.slice(buf),
) {
Expand All @@ -283,7 +251,7 @@ impl Stringifier {
}
}
}
if has_scoped { Version::V3 } else { Version::V2 }
Version::V3
}

fn save_from_binary_inner(
Expand Down
12 changes: 6 additions & 6 deletions test/cli/install/__snapshots__/bun-install-registry.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ exports[`auto-install symlinks (and junctions) are created correctly in the inst

exports[`text lockfile workspace sorting 1`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down Expand Up @@ -45,7 +45,7 @@ exports[`text lockfile workspace sorting 1`] = `

exports[`text lockfile workspace sorting 2`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down Expand Up @@ -88,7 +88,7 @@ exports[`text lockfile workspace sorting 2`] = `

exports[`text lockfile --frozen-lockfile 1`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down Expand Up @@ -120,7 +120,7 @@ exports[`text lockfile --frozen-lockfile 1`] = `

exports[`binaries each type of binary serializes correctly to text lockfile 1`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down Expand Up @@ -170,7 +170,7 @@ exports[`binaries root resolution bins 1`] = `

exports[`hoisting text lockfile is hoisted 1`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down Expand Up @@ -280,7 +280,7 @@ exports[`outdated NO_COLOR works 1`] = `

exports[`it should ignore peerDependencies within workspaces 1`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down
20 changes: 10 additions & 10 deletions test/cli/install/__snapshots__/bun-lock.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

exports[`should write plaintext lockfiles 1`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand All @@ -21,7 +21,7 @@ exports[`should write plaintext lockfiles 1`] = `

exports[`should escape names 1`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand All @@ -48,7 +48,7 @@ exports[`should escape names 1`] = `

exports[`should be the default save format 1`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand All @@ -67,7 +67,7 @@ exports[`should be the default save format 1`] = `

exports[`should be the default save format 2`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand All @@ -89,7 +89,7 @@ exports[`should be the default save format 2`] = `

exports[`should save the lockfile if --save-text-lockfile and --frozen-lockfile are used 1`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand All @@ -108,7 +108,7 @@ exports[`should save the lockfile if --save-text-lockfile and --frozen-lockfile

exports[`should save the lockfile if --save-text-lockfile and --frozen-lockfile are used 2`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand All @@ -130,7 +130,7 @@ exports[`should save the lockfile if --save-text-lockfile and --frozen-lockfile

exports[`should convert a binary lockfile with invalid optional peers 1`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
Expand Down Expand Up @@ -300,7 +300,7 @@ exports[`should not deduplicate bundled packages with un-bundled packages 2`] =

exports[`should not change formatting unexpectedly 2`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down Expand Up @@ -400,7 +400,7 @@ exports[`should not change formatting unexpectedly 2`] = `

exports[`should sort overrides before comparing 1`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down Expand Up @@ -438,7 +438,7 @@ exports[`should sort overrides before comparing 1`] = `

exports[`should include unused resolutions in the lockfile 1`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down
6 changes: 3 additions & 3 deletions test/cli/install/__snapshots__/catalogs.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

exports[`basic detect changes (bun.lock) 1`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down Expand Up @@ -37,7 +37,7 @@ exports[`basic detect changes (bun.lock) 1`] = `

exports[`basic detect changes (bun.lock) 2`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down Expand Up @@ -72,7 +72,7 @@ exports[`basic detect changes (bun.lock) 2`] = `

exports[`basic detect changes (bun.lock) 3`] = `
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down
4 changes: 2 additions & 2 deletions test/cli/install/bun-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9449,7 +9449,7 @@ describe.concurrent("bun-install", () => {
expect(await exists(join(ctx.package_dir, "bun.lockb"))).toBeFalse();
expect(await file(join(ctx.package_dir, "bun.lock")).text()).toMatchInlineSnapshot(`
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down Expand Up @@ -10251,7 +10251,7 @@ it("installs file: dependencies that depend on each other", async () => {
using dir = tempDir("file-dep-cycle", fileDepCycleFixture);
expect(await installFileDepCycle(String(dir))).toMatchInlineSnapshot(`
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down
2 changes: 1 addition & 1 deletion test/cli/install/bun-workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2464,7 +2464,7 @@ test("matching workspace devDependency and npm peerDependency", async () => {
expect((await file(join(packageDir, "bun.lock")).text()).replaceAll(/localhost:\d+/g, "localhost:1234"))
.toMatchInlineSnapshot(`
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down
4 changes: 2 additions & 2 deletions test/cli/install/config-version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe.concurrent("configVersion", () => {

expect(await file(join(packageDir, "bun.lock")).text()).toMatchInlineSnapshot(`
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down Expand Up @@ -108,7 +108,7 @@ describe.concurrent("configVersion", () => {

expect(await file(join(packageDir, "bun.lock")).text()).toMatchInlineSnapshot(`
"{
"lockfileVersion": 2,
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
Expand Down
Loading
Loading