Content-addressable store
How LPM CLI deduplicates package content while keeping project-writable dependency files isolated from canonical store bytes.
Every package LPM CLI downloads goes into a single global store at ~/.lpm/store/. Canonical package content is deduplicated there, and projects reuse graph-keyed wrapper directories with their dependency symlinks already laid out. LPM CLI keeps the writable files exposed through project dependencies on independent inodes, so a write through node_modules cannot alter the canonical bytes used to materialize future entries.
The default layout is v2. An experimental v3 layout adds file-level content addressing and can be selected explicitly with LPM_STORE_VERSION=v3; LPM CLI never selects it automatically based on the operating system, filesystem, or CI environment.
This page covers the design — what the store is, how packages get into it, how projects reach into it from node_modules/, and the maintenance surface.
On-disk layout
~/.lpm/store/
└── v2/
├── objects/ ← extracted package bytes
│ ├── sha512-aabb…/ ← one entry per content hash
│ │ ├── package.json
│ │ ├── index.js
│ │ └── …
│ └── …
├── links/ ← per-graph wrapper directories
│ ├── react@19.2.4+a1b2c3d4/
│ │ ├── node_modules/
│ │ │ ├── react/ ← link from objects/<sri>
│ │ │ ├── scheduler/ ← symlink to a sibling link entry
│ │ │ └── …
│ │ └── .lpm-link-meta.json ← sidecar (object SRI, dep targets)
│ └── …
└── compat/ ← macOS cached compatibility islands
├── <island-key>/
│ ├── <graph-key>/node_modules/<pkg>/
│ └── .lpm-island-complete
└── …Three main arms under the default v2/ layout:
objects/<sri>/— content-addressable extracted bytes. One entry per unique tarball content; shared by every package version with that hash. The SRI is the same one verified against the lockfile.links/<graph-key>/— per-graph wrapper directories. The graph-key folds in the package's name, version, platform tuple, linker mode (hoisted vs isolated produces distinct keys), peer-context (empty in hoisted mode), dep edges, alias edges, root-link names, source disambiguator (Registry vs Tarball/Git), and patch fingerprint (alpm patch'd install gets its own link directory and never shares bytes with the unpatched coords). Two installations of the same(name, version)are interchangeable iff every one of those inputs matches — so projects with compatible graphs share the same link entry, while a hoisted vs isolated install of the same dep set produce two link entries.compat/<island-key>/— macOS-only cached compatibility islands for framework/tooling bins that need a project-local dependency island. The island key includes each entry's graph key, source SRI, and current link-entry content digest, so stable-SRI local sources get a fresh island when their bytes change.
Each link entry's node_modules/<pkg>/ is an independent copy-on-write clone or copy of the corresponding objects/<sri>/ directory. Sibling entries (scheduler/ next to react/ above) are symlinks pointing at other graph-keys' link entries — not copies. The canonical object and the project-writable package tree never share a hardlink inode.
Experimental v3 retains objects/, links/, compat/, and build-cache directories, then adds a file-level CAS:
~/.lpm/store/v3/
├── objects/<sri>/ ← extracted object projections
├── links/<graph-key>/ ← graph-keyed wrapper directories
├── blobs/blake3/<shard>/<digest>-<mode>
├── trees/<shard>/<tree-digest>.msgpack
├── sources/<shard>/<source-digest>.msgpack
├── metadata/source-validations/<shard>/…
└── materialized/<shard>/<tree-digest>/ ← macOS clone source cacheBlob identity includes the BLAKE3 content digest, normalized file mode, and size. Tree manifests describe directories, files, and permitted local-source symlinks. The objects/ projection may share hardlink inodes with immutable blobs inside the store, but link entries still receive independent writable inodes before a project can reach them.
How packages enter the store
With the default v2 layout, lpm install extracts a package into the store exactly once per content hash and materializes a link entry exactly once per graph-key.
- Existence check (object). If
~/.lpm/store/v2/objects/<sri>/exists, skip extraction entirely. Content-level dedup. - Extract. Gzip-decompress + tar-walk into a staging dir under
~/.lpm/store/v2/objects/. - Security scan. Behavioral analysis runs against the extracted source as part of the same pass. Results persist into
.lpm-security.jsonnext to the package files so future installs and audits don't re-scan. See Security audit. - Finalize object. Write
.integrity(the verified SRI) plus.lpm-object-integrity(the reuse-validation sidecar), then atomic-rename the staging dir into its finalobjects/<sri>/location. - Existence check (link entry). If
~/.lpm/store/v2/links/<graph-key>/exists, skip materialization. - Materialize link entry. Clone or copy the package bytes from
objects/<sri>/into the link entry'snode_modules/<pkg>/. Write sibling-dep symlinks for the package's declared deps. Write the.lpm-link-meta.jsonsidecar. - Finalize link entry. Atomic-rename the staging dir into its final
links/<graph-key>/location.
The atomic renames are the visibility boundary. A concurrent install racing for the same content or graph-key either sees the entry or doesn't — never a partially-extracted state.
Experimental v3 follows the same object/link publication flow while ingesting each extracted regular file into blobs/, publishing a content-addressed tree manifest, and recording the source-to-tree mapping. Existing v2 content migrates lazily when an explicit v3 install needs it; switching back to v2 also rematerializes lazily and works offline when the required v3 content is present.
Store integrity mode
LPM CLI validates an existing objects/<sri>/ directory before reusing it. The persistent mode lives in ~/.lpm/config.toml > integrity and is managed by lpm config integrity:
lpm config integrity --set source # default
lpm config integrity --set tree # stricter, slowersource is the default. It checks the object's source identity sidecar against the expected source SRI without rehashing every expanded file, so warm installs stay fast.
tree rehashes the expanded object tree before reuse and records a .lpm-tree-snapshot.json metadata fast path for later checks. Use it when detecting local tampering or corruption inside ~/.lpm/store/v2/objects/ matters more than warm-install speed.
Experimental v3 validates source records and tree manifests, fingerprints every referenced blob's filesystem identity and change metadata, and rehashes blobs when that fingerprint changes. It does not weaken same-size tamper detection by trusting restored modification times.
This is separate from lpm install --strict-integrity: that flag controls whether tarball URL dependencies must declare an inline SRI before first use. integrity = "tree" controls how already-expanded v2 store objects are validated before reuse.
How projects reach into the store
Project node_modules/<pkg>/ is a symlink straight into the selected layout's global link entry:
<project>/node_modules/react
→ ~/.lpm/store/v2/links/react@19.2.4+a1b2c3d4/node_modules/react
<project>/node_modules/.bin/
└── (real dir; bin shims are project-local)When Node resolves require("scheduler") from inside react/, it walks up to the link entry's node_modules/ and finds scheduler/ as a sibling symlink — pointing at another link entry that scheduler is materialized under. Same Node-resolver semantics as a flat or pnpm-isolated layout, just with the wrapper tree relocated to a global, shared location.
| Filesystem | Object → link-entry mechanism |
|---|---|
| macOS on APFS | Recursive clonefile(2) copy-on-write clone. |
| Linux on Btrfs, reflink-enabled XFS, bcachefs, and other reflink-capable filesystems | Per-file copy-on-write reflink via ioctl(FICLONE). |
| Linux on ext4 or another non-reflink filesystem | Independent file copy. |
| Windows | Independent file copy. |
There is no hardlink fallback from canonical object/blob bytes into project-writable package files. Copy-on-write filesystems provide physical sharing with independent inodes; other filesystems preserve the same mutation-isolation property by storing independent copies.
Project node_modules/<pkg> symlinks themselves are real symlinks on every platform (Windows uses directory symlinks / junctions).
Cross-project sharing
Because link entries are keyed by graph context — not by project — every project that resolves the same dependency graph reuses the same on-disk wrapper directory. Five projects using react@19.2.4 with the same peer-context cost one materialization, not five. The first install pays the materialization cost; the rest are pure symlink creation.
This survives rm -rf node_modules. The canonical bytes and wrapper symlinks live in the selected store-version directory, not in the project. A re-install only needs to recreate the project-side node_modules/<pkg> symlinks — which is why warm install is fast.
Isolated vs hoisted
Both linker modes use the same underlying objects + link entries — they differ only in how the project's node_modules/ is shaped:
# Hoisted (default — v2 virtual-store layout)
<project>/node_modules/
react/ → ~/.lpm/store/<version>/links/react@…/node_modules/react
…
.bin/
react → …# Isolated (--linker=isolated, auto-default for workspaces)
<project>/node_modules/
react/ → ~/.lpm/store/<version>/links/react@…/node_modules/react
.bin/
react → …Hoisted exposes root dependencies at the project root and lets each package see its package-local dependency links inside the shared link entry. Isolated only exposes direct root deps and keeps each consumer's dependency view strict. Both reach the same canonical bytes via the global store.
Where the store lives
| Path | Override with |
|---|---|
~/.lpm/store/ (default) | LPM_HOME=<path> (moves the entire LPM CLI root) |
LPM_HOME is useful in CI for hermetic runs — point it at a workspace-local directory and the store survives only as long as the runner does.
If LPM_HOME ends up on a network filesystem, LPM CLI emits a one-time warning at startup pointing at performance issues — copy-on-write cloning, file copies, and metadata-heavy store operations can degrade badly over NFS.
Maintenance
The store grows monotonically as new graph contexts appear. Two surfaces clean it up:
lpm cache prune— reference-aware orphan removal. Walks~/.lpm/known-projects.json(the registry of every project LPM CLI has installed into), traces each project'snode_modules/symlinks back into the v2 and v3 stores, and removes link entries + objects no longer reachable from any registered project. For v3 it also removes unreferenced source metadata, trees, blobs, and materialized tree caches. Cached compatibility islands are removed by crash recovery and optional LRU age. Safe to run regularly. Default is dry-run; pass--applyto delete.lpm store— verify integrity, print the store path, blunt full wipe.
lpm cache prune # preview orphans
lpm cache prune --apply # actually remove
lpm cache prune --max-age 30d --apply # only prune entries last touched > 30d ago
lpm store path # print ~/.lpm/store/
lpm store verify # fast structural + CAS metadata check
lpm store verify --deep # also rehash v3 blobs and cross-check lpm.lockIf the project registry is missing, corrupt, or unreadable AND no explicit
--project <path> is supplied, lpm cache prune skips only the link/object
reachability walk (without trustworthy roots, every link entry would look
unreachable). --apply still performs reachability-independent crash recovery
and age eviction for compatibility islands and native build artifacts, removes
their orphaned locks, and runs the global-install tombstone sweep. The warning
names the cause; for corrupt registries it includes the parser's reason so you
can repair or delete the file. Run lpm install in a project to populate a
fresh registry, or pass --project <path> to walk a specific project.
Concurrency
The store is reads-mostly under normal operation. Three scenarios coordinate explicitly:
- Concurrent extract for the same content. The atomic rename + the existence check race-handle this: whichever install wins the rename publishes the entry; the other install sees the win on the next iteration and skips.
- Concurrent install vs.
lpm cache prune. A writer-preference reader/writer protocol (data lock + writer-intent gate + writer-queue baton at~/.lpm/store/.gc.lock*) lets multiple installs run concurrently while serializing prune behind in-flight installs. Seelpm store— Locking model. - Concurrent prune invocations. Serialize through the same protocol.
Security caches
After the security scan finishes, results persist alongside the package as .lpm-security.json inside the object directory. lpm audit reads from these caches; a second lpm audit run on an unchanged tree is essentially free. lpm store verify --fix refreshes stale security caches without re-extracting.
When does this matter?
- Disk usage on a multi-project machine — without the global link entries, every project would re-materialize its own wrapper tree. With them, the wrapper population is shared across every project with a matching graph context.
- Warm install speed —
rm -rf node_modulessurvives the global store. Re-install is symlink creation, not extraction. - Cold install across projects — the second project to need a given dep at a given graph context just makes a project-side symlink; no extraction, no clonefile.
- Audit speed — security analysis runs once per content hash, not per project.
See also
lpm cache prune— orphan removal for the global storelpm store— integrity, path, blunt full wipe- Lockfile — what records the integrity hash the store verifies against
lpm install --linker— picks isolated vs hoisted layout- Environment variables —
LPM_HOMEto relocate the store - Security audit — the analysis pipeline that writes
.lpm-security.json