@vltpkg/graph 1.3.3 → 1.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,7 @@ import type { ExplicitAddMap, TransientAddMap, TransientRemoveMap } from './type
18
18
  *
19
19
  * 1. Process all deps at the current level in parallel
20
20
  * 2. After each level, run `postPlacementPeerCheck` to handle peer contexts
21
- * 3. Collect child deps for the next level
21
+ * 3. Collect child deps for the next level, each node once
22
22
  * 4. Repeat until no more deps to process
23
23
  *
24
24
  * **Peer Context Isolation**: Each workspace importer gets its own peer context
@@ -621,7 +621,7 @@ const processPlacementTasks = async (graph, options, placementTasks, add, modifi
621
621
  *
622
622
  * 1. Process all deps at the current level in parallel
623
623
  * 2. After each level, run `postPlacementPeerCheck` to handle peer contexts
624
- * 3. Collect child deps for the next level
624
+ * 3. Collect child deps for the next level, each node once
625
625
  * 4. Repeat until no more deps to process
626
626
  *
627
627
  * **Peer Context Isolation**: Each workspace importer gets its own peer context
@@ -680,8 +680,6 @@ export const appendNodes = async (packageInfo, graph, fromNode, deps, scurry, op
680
680
  // Fetch all manifests at this level in parallel without mutating the graph.
681
681
  // This phase is read-only to avoid race conditions from network timing.
682
682
  const fetchResults = await Promise.all(currentLevelDeps.map(async ({ node, deps: nodeDeps, modifierRefs: nodeModifierRefs, peerContext, depth, }) => {
683
- // Cycle prevention: mark as seen when starting to process
684
- seen.add(node.id);
685
683
  // Fetch manifests and collect tasks (no graph mutations)
686
684
  const result = await fetchManifestsForDeps(packageInfo, graph, node,
687
685
  // Sort by name for deterministic ordering (reproducible builds)
@@ -740,17 +738,16 @@ export const appendNodes = async (packageInfo, graph, fromNode, deps, scurry, op
740
738
  // ============================================================
741
739
  // STEP 3: COLLECT CHILD DEPS FOR NEXT LEVEL
742
740
  // ============================================================
741
+ /* c8 ignore next */
742
+ const nextDepth = (currentLevelDeps[0]?.depth ?? 0) + 1;
743
743
  for (const childDepsToProcess of levelResults) {
744
744
  for (const childDep of childDepsToProcess) {
745
- // Skip already-seen nodes (cycle prevention)
746
- if (!seen.has(childDep.node.id)) {
747
- /* c8 ignore next */
748
- const currentDepth = currentLevelDeps[0]?.depth ?? 0;
749
- nextLevelDeps.push({
750
- ...childDep,
751
- depth: currentDepth + 1,
752
- });
753
- }
745
+ // seen at enqueue: skips cycles and same-level duplicates
746
+ // (first in sorted order wins)
747
+ if (seen.has(childDep.node.id))
748
+ continue;
749
+ seen.add(childDep.node.id);
750
+ nextLevelDeps.push({ ...childDep, depth: nextDepth });
754
751
  }
755
752
  }
756
753
  // Advance to next BFS level
@@ -3,6 +3,7 @@ import { graphStep } from '@vltpkg/output';
3
3
  import { load as loadActual } from "../actual/load.js";
4
4
  import { load as loadVirtual } from "../lockfile/load.js";
5
5
  import { buildIdealFromStartingGraph } from "./build-ideal-from-starting-graph.js";
6
+ import { prefetchResolve } from "./prefetch-resolve.js";
6
7
  import { isPathSecurityError } from "../path-security-error.js";
7
8
  import { splitDepID } from '@vltpkg/dep-id';
8
9
  const getMap = (m) => m ?? new Map();
@@ -60,15 +61,25 @@ export const build = async (options) => {
60
61
  monorepo,
61
62
  });
62
63
  }
63
- const res = await buildIdealFromStartingGraph({
64
- ...options,
65
- scurry,
66
- add: getMap(options.add),
67
- graph,
68
- packageInfo,
69
- remove: getMap(options.remove),
70
- actual: options.actual,
71
- });
64
+ // Start resolving the importers' dependencies server-side, one request
65
+ // to the default registry. manifest() picks the answers up as they land,
66
+ // and nothing reads them once the ideal graph is built.
67
+ const releaseResolve = prefetchResolve(graph, packageInfo, options);
68
+ let res;
69
+ try {
70
+ res = await buildIdealFromStartingGraph({
71
+ ...options,
72
+ scurry,
73
+ add: getMap(options.add),
74
+ graph,
75
+ packageInfo,
76
+ remove: getMap(options.remove),
77
+ actual: options.actual,
78
+ });
79
+ }
80
+ finally {
81
+ releaseResolve();
82
+ }
72
83
  done();
73
84
  // when adding or removing a new dependency from a file dep,
74
85
  // validate the receiver node was present in the graph
@@ -0,0 +1,20 @@
1
+ import type { PackageInfoClient } from '@vltpkg/package-info';
2
+ import type { SpecOptions } from '@vltpkg/spec';
3
+ import type { Graph } from '../graph.ts';
4
+ /**
5
+ * Start a server-side resolve for the importers' dependencies the starting
6
+ * graph does not already satisfy, one request to the default registry,
7
+ * without waiting for it: the request overlaps whatever the build does
8
+ * before it asks for its first manifest. A graph loaded whole from a
9
+ * lockfile or node_modules sends nothing.
10
+ *
11
+ * Returns the function that releases the request, for the build to call
12
+ * once it has placed its last node.
13
+ *
14
+ * Fails soft in every direction: a registry that does not serve the
15
+ * endpoint, a request that errors, and a spec it does not resolve all
16
+ * leave the per-name path to handle it, exactly as before.
17
+ */
18
+ export declare const prefetchResolve: (graph: Graph, packageInfo: PackageInfoClient, options: SpecOptions & {
19
+ modifiers?: unknown;
20
+ }) => (() => void);
@@ -0,0 +1,85 @@
1
+ import { detectLibc } from '@vltpkg/pick-manifest';
2
+ const nothing = () => { };
3
+ /**
4
+ * Start a server-side resolve for the importers' dependencies the starting
5
+ * graph does not already satisfy, one request to the default registry,
6
+ * without waiting for it: the request overlaps whatever the build does
7
+ * before it asks for its first manifest. A graph loaded whole from a
8
+ * lockfile or node_modules sends nothing.
9
+ *
10
+ * Returns the function that releases the request, for the build to call
11
+ * once it has placed its last node.
12
+ *
13
+ * Fails soft in every direction: a registry that does not serve the
14
+ * endpoint, a request that errors, and a spec it does not resolve all
15
+ * leave the per-name path to handle it, exactly as before.
16
+ */
17
+ export const prefetchResolve = (graph, packageInfo, options) => {
18
+ // a modifier can swap any spec mid-graph, taking the server's whole
19
+ // closure off the client's real one; those installs resolve locally
20
+ if (options.modifiers)
21
+ return nothing;
22
+ const registry = options.registry;
23
+ if (!registry)
24
+ return nothing;
25
+ const scopes = Object.keys(options['scoped-registries'] ?? {});
26
+ // a name under a scoped registry is that registry's; it is not sent to
27
+ // this one at all, and `stop` keeps the server from walking into it
28
+ const scoped = (name) => scopes.some(scope => name.startsWith(`${scope}/`));
29
+ const roots = [];
30
+ const seen = new Set();
31
+ for (const importer of graph.importers) {
32
+ const manifest = importer.manifest;
33
+ if (!manifest)
34
+ continue;
35
+ for (const field of [
36
+ 'dependencies',
37
+ 'devDependencies',
38
+ 'optionalDependencies',
39
+ ]) {
40
+ const deps = manifest[field];
41
+ if (!deps)
42
+ continue;
43
+ for (const [name, spec] of Object.entries(deps)) {
44
+ if (typeof spec !== 'string')
45
+ continue;
46
+ // protocols, workspace and file specs are the client's to resolve
47
+ if (spec.includes(':') || scoped(name))
48
+ continue;
49
+ // an edge the starting graph already resolves to a node that
50
+ // satisfies this very spec is settled; the build keeps it as is
51
+ const edge = importer.edgesOut.get(name);
52
+ if (edge?.to && edge.spec.bareSpec === spec && edge.valid())
53
+ continue;
54
+ const key = `${name}@${spec}`;
55
+ if (seen.has(key))
56
+ continue;
57
+ seen.add(key);
58
+ roots.push({ name, spec });
59
+ }
60
+ }
61
+ }
62
+ if (!roots.length)
63
+ return nothing;
64
+ const have = [];
65
+ for (const node of graph.nodes.values()) {
66
+ if (node.manifest &&
67
+ node.name &&
68
+ node.version &&
69
+ !scoped(node.name)) {
70
+ have.push(`${node.name}@${node.version}`);
71
+ }
72
+ }
73
+ return packageInfo.prefetchResolve(registry, {
74
+ roots,
75
+ have,
76
+ ...(scopes.length ? { stop: { scopes } } : {}),
77
+ platform: {
78
+ os: process.platform,
79
+ cpu: process.arch,
80
+ node: process.versions.node,
81
+ // undefined never serializes, so no libc means the field is absent
82
+ libc: detectLibc(),
83
+ },
84
+ });
85
+ };
@@ -8,6 +8,7 @@ import { optionalFail } from "./optional-fail.js";
8
8
  import { binChmod } from "./bin-chmod.js";
9
9
  import { hasBindingGyp } from "./binding-gyp.js";
10
10
  import { scriptsManifest } from "./scripts-manifest.js";
11
+ import { unshare } from "./unshare.js";
11
12
  export const build = async (diff, packageJson, scurry, allowScriptsNodes) => {
12
13
  const graph = diff.to;
13
14
  const nodes = nonEmptyList([...graph.importers]);
@@ -81,27 +82,30 @@ const visit = async (packageJson, scurry, node, signal, _path) => {
81
82
  const hasImplicitInstall = hasBindingGyp(node, scurry) && !install && !preinstall;
82
83
  // if it has install script or binding.gyp (implicit install), run it
83
84
  const runInstall = !!(install || preinstall || postinstall) || hasImplicitInstall;
85
+ // if it's an importer or git, run prepare
86
+ const prepable = node.id.startsWith('git') || node.importer || !node.inVltStore();
87
+ const runPrepare = !!(prepare || preprepare || postprepare) && prepable;
88
+ // store links share inodes with the global store
89
+ if ((runInstall || runPrepare) && node.inVltStore())
90
+ await unshare(dir);
84
91
  if (runInstall) {
85
92
  await run({
86
93
  signal,
87
94
  arg0: 'install',
88
95
  ignoreMissing: true,
89
96
  packageJson,
90
- cwd: node.resolvedLocation(scurry),
97
+ cwd: dir,
91
98
  projectRoot: node.projectRoot,
92
99
  manifest,
93
100
  });
94
101
  }
95
- // if it's an importer or git, run prepare
96
- const prepable = node.id.startsWith('git') || node.importer || !node.inVltStore();
97
- const runPrepare = !!(prepare || preprepare || postprepare) && prepable;
98
102
  if (runPrepare) {
99
103
  await run({
100
104
  signal,
101
105
  arg0: 'prepare',
102
106
  ignoreMissing: true,
103
107
  packageJson,
104
- cwd: node.resolvedLocation(scurry),
108
+ cwd: dir,
105
109
  projectRoot: node.projectRoot,
106
110
  manifest,
107
111
  });
@@ -20,7 +20,6 @@ const getOptionalFailedNodeRemover = (node, diff) => {
20
20
  */
21
21
  export const extractNode = async (node, scurry, remover, options, packageInfo, diff) => {
22
22
  node.extracted = true;
23
- const { manifest = {} } = node;
24
23
  const target = node.resolvedLocation(scurry);
25
24
  const from = scurry.resolve('');
26
25
  const spec = hydrate(node.id, node.name, options);
@@ -35,18 +34,11 @@ export const extractNode = async (node, scurry, remover, options, packageInfo, d
35
34
  error: new Error('Platform check failed or package deprecated'),
36
35
  };
37
36
  }
38
- // the registry manifest can declare install scripts that the
39
- // tarball's package.json, read by the global store, lacks
40
- const { scripts, hasInstallScript } = manifest;
41
37
  const extractOptions = {
42
38
  from,
43
39
  integrity,
44
40
  resolved,
45
41
  fromLockfile: node.resolvedFromLockfile,
46
- installScripts: !!(hasInstallScript ||
47
- scripts?.install ||
48
- scripts?.preinstall ||
49
- scripts?.postinstall),
50
42
  };
51
43
  const extracted = (r) => {
52
44
  // Store computed integrity for git/remote deps
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Replace every file of `dir` that is hardlinked (e.g. from the global
3
+ * store) by a private copy, so a script run in it cannot write into
4
+ * the store. package.json goes last: a private package.json means
5
+ * nothing is shared, which the global store link keeps true.
6
+ */
7
+ export declare const unshare: (dir: string) => Promise<void>;
@@ -0,0 +1,59 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { lstatSync, readdirSync, rmSync } from 'node:fs';
3
+ import { chmod, readFile, rename, rm, writeFile, } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { callLimit } from 'promise-call-limit';
6
+ const TMP = '.vlt-unshare-';
7
+ const own = async (p, mode, tmp) => {
8
+ const body = await readFile(p);
9
+ try {
10
+ await writeFile(tmp, body, { mode: mode & 0o777, flag: 'wx' });
11
+ // same modes as a copy from the store
12
+ if (mode & 0o111)
13
+ await chmod(tmp, mode & 0o777);
14
+ // atomic: p is never missing
15
+ await rename(tmp, p);
16
+ }
17
+ catch (er) {
18
+ await rm(tmp, { force: true });
19
+ throw er;
20
+ }
21
+ };
22
+ /**
23
+ * Replace every file of `dir` that is hardlinked (e.g. from the global
24
+ * store) by a private copy, so a script run in it cannot write into
25
+ * the store. package.json goes last: a private package.json means
26
+ * nothing is shared, which the global store link keeps true.
27
+ */
28
+ export const unshare = async (dir) => {
29
+ const pjPath = join(dir, 'package.json');
30
+ const pj = lstatSync(pjPath, { throwIfNoEntry: false });
31
+ if (!pj || pj.nlink < 2)
32
+ return;
33
+ // random per call: never clashes with another run's tmp
34
+ const tag = `${TMP}${randomBytes(4).toString('hex')}-`;
35
+ let n = 0;
36
+ const shared = [];
37
+ // by hand: a recursive readdir follows dir symlinks
38
+ const walk = (d) => {
39
+ for (const ent of readdirSync(d, { withFileTypes: true })) {
40
+ const p = join(d, ent.name);
41
+ if (ent.isDirectory())
42
+ walk(p);
43
+ else if (ent.isFile() && p !== pjPath) {
44
+ const { mode, nlink } = lstatSync(p);
45
+ if (nlink > 1) {
46
+ const tmp = join(d, `${tag}${n++}`);
47
+ shared.push(() => own(p, mode, tmp));
48
+ }
49
+ else if (ent.name.startsWith(TMP)) {
50
+ // a killed run's tmp copy
51
+ rmSync(p);
52
+ }
53
+ }
54
+ }
55
+ };
56
+ walk(dir);
57
+ await callLimit(shared, { limit: 16, rejectLate: true });
58
+ await own(pjPath, pj.mode, join(dir, `${tag}${n}`));
59
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vltpkg/graph",
3
3
  "description": "A library that helps understanding & expressing what happens on an install",
4
- "version": "1.3.3",
4
+ "version": "1.3.5",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/vltpkg/vltpkg.git",
@@ -13,26 +13,26 @@
13
13
  "url": "http://vlt.sh"
14
14
  },
15
15
  "dependencies": {
16
- "@vltpkg/cmd-shim": "1.3.3",
17
- "@vltpkg/dep-id": "1.3.3",
18
- "@vltpkg/dss-breadcrumb": "1.3.3",
19
- "@vltpkg/error-cause": "1.3.3",
20
- "@vltpkg/fast-split": "1.3.3",
21
- "@vltpkg/graph-run": "1.3.3",
22
- "@vltpkg/init": "1.3.3",
23
- "@vltpkg/output": "1.3.3",
24
- "@vltpkg/package-info": "1.3.3",
25
- "@vltpkg/package-json": "1.3.3",
26
- "@vltpkg/pick-manifest": "1.3.3",
27
- "@vltpkg/query": "1.3.3",
28
- "@vltpkg/rollback-remove": "1.3.3",
29
- "@vltpkg/run": "1.3.3",
30
- "@vltpkg/satisfies": "1.3.3",
31
- "@vltpkg/security-archive": "1.3.3",
32
- "@vltpkg/spec": "1.3.3",
33
- "@vltpkg/types": "1.3.3",
34
- "@vltpkg/vlt-json": "1.3.3",
35
- "@vltpkg/workspaces": "1.3.3",
16
+ "@vltpkg/cmd-shim": "1.3.5",
17
+ "@vltpkg/dep-id": "1.3.5",
18
+ "@vltpkg/dss-breadcrumb": "1.3.5",
19
+ "@vltpkg/error-cause": "1.3.5",
20
+ "@vltpkg/fast-split": "1.3.5",
21
+ "@vltpkg/graph-run": "1.3.5",
22
+ "@vltpkg/init": "1.3.5",
23
+ "@vltpkg/output": "1.3.5",
24
+ "@vltpkg/package-info": "1.3.5",
25
+ "@vltpkg/package-json": "1.3.5",
26
+ "@vltpkg/pick-manifest": "1.3.5",
27
+ "@vltpkg/query": "1.3.5",
28
+ "@vltpkg/rollback-remove": "1.3.5",
29
+ "@vltpkg/run": "1.3.5",
30
+ "@vltpkg/satisfies": "1.3.5",
31
+ "@vltpkg/security-archive": "1.3.5",
32
+ "@vltpkg/spec": "1.3.5",
33
+ "@vltpkg/types": "1.3.5",
34
+ "@vltpkg/vlt-json": "1.3.5",
35
+ "@vltpkg/workspaces": "1.3.5",
36
36
  "path-scurry": "^2.0.1",
37
37
  "promise-call-limit": "^3.0.2"
38
38
  },