@vltpkg/graph 1.0.7 → 1.0.8

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.
Files changed (38) hide show
  1. package/dist/actual/load.js +1 -0
  2. package/dist/dependencies.d.ts +16 -0
  3. package/dist/dependencies.js +24 -0
  4. package/dist/fixup-added-names.js +4 -2
  5. package/dist/graph.d.ts +41 -4
  6. package/dist/graph.js +145 -25
  7. package/dist/ideal/append-nodes.d.ts +2 -2
  8. package/dist/ideal/append-nodes.js +226 -60
  9. package/dist/ideal/build-ideal-from-starting-graph.js +28 -0
  10. package/dist/ideal/canonicalize-peer-ids.d.ts +44 -0
  11. package/dist/ideal/canonicalize-peer-ids.js +421 -0
  12. package/dist/ideal/get-importer-specs.d.ts +5 -1
  13. package/dist/ideal/get-importer-specs.js +51 -24
  14. package/dist/ideal/move-peer-store-dirs.d.ts +7 -0
  15. package/dist/ideal/move-peer-store-dirs.js +59 -0
  16. package/dist/ideal/peers.d.ts +16 -5
  17. package/dist/ideal/peers.js +187 -31
  18. package/dist/ideal/refresh-ideal-graph.d.ts +11 -2
  19. package/dist/ideal/refresh-ideal-graph.js +62 -4
  20. package/dist/ideal/remove-satisfied-specs.d.ts +11 -2
  21. package/dist/ideal/remove-satisfied-specs.js +18 -1
  22. package/dist/ideal/types.d.ts +20 -0
  23. package/dist/install.js +22 -3
  24. package/dist/lockfile/format-options-change.d.ts +5 -0
  25. package/dist/lockfile/format-options-change.js +20 -0
  26. package/dist/lockfile/load-nodes.js +3 -1
  27. package/dist/lockfile/load.d.ts +7 -1
  28. package/dist/lockfile/load.js +63 -7
  29. package/dist/lockfile/types.d.ts +17 -0
  30. package/dist/modifiers.d.ts +10 -0
  31. package/dist/modifiers.js +45 -9
  32. package/dist/node.d.ts +12 -0
  33. package/dist/node.js +42 -0
  34. package/dist/reify/index.js +30 -18
  35. package/dist/reify/update-importers-package-json.js +12 -2
  36. package/dist/transfer-data/load.js +3 -3
  37. package/dist/uninstall.js +2 -0
  38. package/package.json +21 -21
@@ -1,22 +1,16 @@
1
- import { joinDepIDTuple, joinExtra } from '@vltpkg/dep-id';
1
+ import { baseDepID, getTuple, hydrate, joinDepIDTuple, joinExtra, splitDepID, } from '@vltpkg/dep-id';
2
2
  import { error } from '@vltpkg/error-cause';
3
3
  import { Spec } from '@vltpkg/spec';
4
4
  import { satisfies } from '@vltpkg/satisfies';
5
+ import { parse as parseVersion } from '@vltpkg/semver';
5
6
  import { longDependencyTypes, normalizeManifest } from '@vltpkg/types';
6
7
  import { fixupAddedNames } from "../fixup-added-names.js";
7
- import { shorten } from "../dependencies.js";
8
+ import { addKey, shorten, shouldInstallDepType, } from "../dependencies.js";
8
9
  import { removeOptionalSubgraph } from "../remove-optional-subgraph.js";
9
10
  import { isPathSecurityError } from "../path-security-error.js";
10
11
  import { extractNode } from "../reify/extract-node.js";
11
12
  import { checkPeerEdgesCompatible, endPeerPlacement, forkPeerContext, postPlacementPeerCheck, startPeerPlacement, } from "./peers.js";
12
13
  import { compareByHasPeerDeps } from "./sorting.js";
13
- /**
14
- * Only install devDeps for git dependencies and importers
15
- * Everything else always gets installed
16
- */
17
- const shouldInstallDepType = (node, depType) => depType !== 'devDependencies' ||
18
- node.importer ||
19
- node.id.startsWith('git');
20
14
  /**
21
15
  * Retrieve the {@link DepID} and location for a `file:` type {@link Node}.
22
16
  */
@@ -42,61 +36,99 @@ const getFileTypeInfo = (spec, fromNode, scurry) => {
42
36
  };
43
37
  };
44
38
  const isStringArray = (a) => Array.isArray(a) && !a.some(b => typeof b !== 'string');
39
+ const actualBases = new WeakMap();
40
+ const hasActualBase = (actual, id) => {
41
+ let bases = actualBases.get(actual);
42
+ if (!bases) {
43
+ bases = new Set([...actual.nodes.keys()].map(baseDepID));
44
+ actualBases.set(actual, bases);
45
+ }
46
+ return bases.has(baseDepID(id));
47
+ };
48
+ /**
49
+ * Can the lockfile target still serve this spec? Exact for sources
50
+ * satisfies() understands; loose fallback for sources it cannot verify
51
+ * (named jsr registries, dist-tags): only for a registry node stamped
52
+ * with the same type+registry segments a fresh node for this spec would
53
+ * get, so a locked node from another source is never reused.
54
+ */
55
+ const lockedFits = (n, spec, fromNode, graph) => {
56
+ const final = spec.final;
57
+ if (satisfies(n.id, final, fromNode.location, graph.projectRoot, graph.monorepo)) {
58
+ return true;
59
+ }
60
+ if (final.type !== 'registry')
61
+ return false;
62
+ const [idType, idRegistry] = splitDepID(n.id);
63
+ if (idType !== 'registry')
64
+ return false;
65
+ const expected = getTuple(spec, {
66
+ name: n.name,
67
+ version: n.version,
68
+ });
69
+ if (idRegistry !== expected[1])
70
+ return false;
71
+ // the package the final spec resolves, never the edge name: an alias
72
+ // (`foo@npm:bar@^1`, `semver@jsr:@std/semver@^1`) points the same edge
73
+ // at a different package, and `final.name` already carries the jsr
74
+ // registry form of the name
75
+ if (n.name !== final.name)
76
+ return false;
77
+ if (!final.range)
78
+ return true;
79
+ /* c8 ignore next */
80
+ if (!n.version)
81
+ return false;
82
+ const version = parseVersion(n.version);
83
+ return !!version && final.range.test(version);
84
+ };
85
+ /**
86
+ * The lockfile target captured for `fromNode → name` before resetEdges().
87
+ * A peer-fork rebuild gives fromNode a provisional `peer.N` id that
88
+ * misses the canonical id key, so retry with the base id key, recorded
89
+ * only when unambiguous across peer copies.
90
+ */
91
+ const findLockedNode = (graph, fromNode, name) => {
92
+ const locked = graph.lockedResolutions;
93
+ if (!locked)
94
+ return;
95
+ let id = locked.get(`${fromNode.id}\0${name}`);
96
+ if (id === undefined) {
97
+ const baseFrom = baseDepID(fromNode.id);
98
+ if (baseFrom !== fromNode.id) {
99
+ id = locked.get(`${baseFrom}\0${name}`);
100
+ }
101
+ }
102
+ return id ? graph.nodes.get(id) : undefined;
103
+ };
45
104
  /**
46
105
  * Try to find a compatible resolution for a dependency, checking peer context.
47
106
  * If the first resolution candidate is incompatible with the peer context,
48
107
  * try other candidates.
49
108
  */
50
- const findCompatibleResolution = (spec, fromNode, graph, peerContext, queryModifier, _peer) => {
51
- // Hoist invariants once
52
- const fromLoc = fromNode.location;
53
- const projectRoot = graph.projectRoot;
54
- const monorepo = graph.monorepo;
109
+ const findCompatibleResolution = (spec, fromNode, graph, peerContext, candidate, pending) => {
55
110
  const final = spec.final;
56
- // Memoize satisfies() results per-node within this resolution attempt
57
- const satisfiesCache = new Map();
58
- const satisfiesFinal = (n) => {
59
- const key = n.id;
60
- const cached = satisfiesCache.get(key);
61
- /* c8 ignore next 3 - optimization: cache hit when same node checked multiple times */
62
- if (cached !== undefined) {
63
- return cached;
64
- }
65
- const result = satisfies(key, final, fromLoc, projectRoot, monorepo);
66
- satisfiesCache.set(key, result);
67
- return result;
68
- };
69
- // Prefer existing edge target if it satisfies the spec.
70
- // This ensures lockfile resolutions are preserved when still valid,
71
- // rather than potentially picking a different satisfying version.
72
- const existingEdge = fromNode.edgesOut.get(spec.name);
73
- let existingNode;
74
- if (existingEdge?.to &&
75
- !existingEdge.to.detached &&
76
- satisfiesFinal(existingEdge.to)) {
77
- existingNode = existingEdge.to;
78
- }
79
- else {
80
- existingNode = graph.findResolution(spec, fromNode, queryModifier);
81
- }
111
+ // the candidate was picked in the pass before the hydrations
112
+ let existingNode = candidate;
82
113
  let peerCompatResult = existingNode ?
83
- checkPeerEdgesCompatible(existingNode, fromNode, peerContext, graph)
114
+ checkPeerEdgesCompatible(existingNode, fromNode, peerContext, graph, pending)
84
115
  : { compatible: true };
85
116
  // CANDIDATE FALLBACK: If first candidate is peer-incompatible, try others
86
117
  // Lazy-load candidates only when fallback needed
87
118
  if (existingNode && !peerCompatResult.compatible) {
88
119
  const candidates = graph.nodesByName.get(final.name);
89
120
  if (candidates && candidates.size > 1) {
90
- for (const candidate of candidates) {
91
- if (candidate === existingNode)
121
+ for (const alt of candidates) {
122
+ if (alt === existingNode)
92
123
  continue;
93
- if (candidate.detached)
124
+ if (alt.detached)
94
125
  continue;
95
- if (!satisfiesFinal(candidate))
126
+ if (!satisfies(alt.id, final, fromNode.location, graph.projectRoot, graph.monorepo)) {
96
127
  continue;
97
- const compat = checkPeerEdgesCompatible(candidate, fromNode, peerContext, graph);
128
+ }
129
+ const compat = checkPeerEdgesCompatible(alt, fromNode, peerContext, graph, pending);
98
130
  if (compat.compatible) {
99
- existingNode = candidate;
131
+ existingNode = alt;
100
132
  peerCompatResult = compat;
101
133
  break;
102
134
  }
@@ -105,6 +137,55 @@ const findCompatibleResolution = (spec, fromNode, graph, peerContext, queryModif
105
137
  }
106
138
  return { existingNode, peerCompatResult };
107
139
  };
140
+ /**
141
+ * A detached lockfile node without a manifest still knows its exact
142
+ * version through its DepID. Rebuild the spec that produced it so the
143
+ * manifest fetch pins the locked version instead of re-resolving the
144
+ * range to the newest satisfying release. Only registry nodes carry a
145
+ * version; anything else keeps the range fetch.
146
+ */
147
+ const lockedVersionFetch = (node, spec, options) => {
148
+ if (spec.final.type !== 'registry' || !node.version)
149
+ return;
150
+ // hydrate() reproduces alias / named / URL / jsr forms from the DepID
151
+ // but memoizes per (name, id) ignoring options, so re-parse its
152
+ // bareSpec with the current options
153
+ const bare = hydrate(node.id, spec.name, options).bareSpec;
154
+ const exact = Spec.parse(spec.name, bare, options);
155
+ // lockedFits only compares the DepID registry name segment; never
156
+ // fetch a locked version from a source other than the one the edge
157
+ // resolves to right now
158
+ if (exact.final.registry !== spec.final.registry)
159
+ return;
160
+ return { spec: exact, version: node.version };
161
+ };
162
+ /**
163
+ * A rebuild without node_modules loads lockfile nodes with no manifest.
164
+ * Fetch the locked version up front and hydrate the node so the reuse
165
+ * and peer compatibility decisions see exactly what the hidden-lockfile
166
+ * path sees; on any miss the node stays bare and the range fetch runs as
167
+ * before. Hydrations are shared across the parallel fetches of one
168
+ * appendNodes run so each node is fetched once.
169
+ */
170
+ const hydrateLockedNode = (node, spec, graph, packageInfo, options, from, hydrations) => {
171
+ const seen = hydrations.get(node.id);
172
+ if (seen)
173
+ return seen;
174
+ const locked = lockedVersionFetch(node, spec, options);
175
+ if (!locked)
176
+ return;
177
+ const hydration = packageInfo
178
+ .manifest(locked.spec, { from })
179
+ .then(manifest => manifest, () => undefined)
180
+ .then(manifest => {
181
+ if (manifest?.version !== locked.version)
182
+ return;
183
+ node.manifest = normalizeManifest(manifest, String(locked.spec));
184
+ graph.manifests.set(node.id, node.manifest);
185
+ });
186
+ hydrations.set(node.id, hydration);
187
+ return hydration;
188
+ };
108
189
  /**
109
190
  * Fetch manifests for dependencies and create placement tasks.
110
191
  *
@@ -121,15 +202,20 @@ const findCompatibleResolution = (spec, fromNode, graph, peerContext, queryModif
121
202
  * **Read-only**: This function no longer mutates the graph. It returns
122
203
  * tasks that will be applied serially in the BFS loop for deterministic ordering.
123
204
  */
124
- const fetchManifestsForDeps = async (packageInfo, graph, fromNode, deps, scurry, peerContext, modifierRefs, depth = 0) => {
205
+ const fetchManifestsForDeps = async (packageInfo, graph, fromNode, deps, scurry, options, hydrations, peerContext, modifierRefs, depth = 0, explicit) => {
125
206
  const fetchTasks = [];
126
207
  const placementTasks = [];
127
208
  const reuseTasks = [];
128
209
  const forkRequests = [];
210
+ // resolve the spec each dep is placed with before any lookup
211
+ const entries = [];
129
212
  for (const { spec: originalSpec, type } of deps) {
130
213
  let spec = originalSpec;
131
214
  const fileTypeInfo = getFileTypeInfo(spec, fromNode, scurry);
132
215
  const activeModifier = modifierRefs?.get(spec.name);
216
+ const isExplicit = !!explicit
217
+ ?.get(fromNode.id)
218
+ ?.has(addKey(originalSpec));
133
219
  // MODIFIER HANDLING: Swap spec if an edge modifier is fully matched
134
220
  // Example: `vlt install --override "react:^19"` changes react's spec
135
221
  const queryModifier = activeModifier?.modifier.query;
@@ -145,18 +231,75 @@ const fetchManifestsForDeps = async (packageInfo, graph, fromNode, deps, scurry,
145
231
  continue;
146
232
  }
147
233
  }
234
+ entries.push({
235
+ spec,
236
+ type,
237
+ fileTypeInfo,
238
+ activeModifier,
239
+ queryModifier,
240
+ isExplicit,
241
+ // read after the modifier swap, so an --override that replaces the
242
+ // tag with a range keeps today's reuse
243
+ explicitTag: isExplicit && !!spec.final.distTag,
244
+ });
245
+ }
246
+ // everything this parent places at this level, by name: the provision
247
+ // source for optional peers of reuse candidates, explicit adds included
248
+ const pendingDeps = new Map();
249
+ for (const e of entries)
250
+ pendingDeps.set(e.spec.name, e);
251
+ // pick the reuse candidate once, here, so the placement loop below
252
+ // does not repeat the lookup, and hydrate it when it is a bare
253
+ // detached copy from a lockfile loaded without node_modules
254
+ const from = scurry.resolve(fromNode.location);
255
+ const pending = [];
256
+ for (const e of entries) {
257
+ // prefer the existing edge target when it still satisfies: lockfile
258
+ // resolutions are preserved rather than re-picked. a rebuild reset
259
+ // every edge, so this only fires when there was no reset, where no
260
+ // node is detached and there is nothing to hydrate either
261
+ const existing = fromNode.edgesOut.get(e.spec.name)?.to;
262
+ if (existing &&
263
+ !existing.detached &&
264
+ satisfies(existing.id, e.spec.final, fromNode.location, graph.projectRoot, graph.monorepo)) {
265
+ e.candidate = existing;
266
+ continue;
267
+ }
268
+ const locked = findLockedNode(graph, fromNode, e.spec.name);
269
+ e.candidate =
270
+ locked && lockedFits(locked, e.spec, fromNode, graph) ? locked
271
+ : graph.findResolution(e.spec, fromNode, e.queryModifier);
272
+ // the tag is fetched below anyway, the locked version is dead weight
273
+ if (e.explicitTag)
274
+ continue;
275
+ if (e.candidate?.detached && !e.candidate.manifest) {
276
+ const hydration = hydrateLockedNode(e.candidate, e.spec, graph, packageInfo, options, from, hydrations);
277
+ if (hydration)
278
+ pending.push(hydration);
279
+ }
280
+ }
281
+ await Promise.all(pending);
282
+ for (const { spec, type, fileTypeInfo, activeModifier, queryModifier, isExplicit, explicitTag, candidate, } of entries) {
148
283
  const peer = type === 'peer' || type === 'peerOptional';
149
284
  // NODE REUSE LOGIC with peer compatibility
150
- const { existingNode, peerCompatResult } = findCompatibleResolution(spec, fromNode, graph, peerContext, queryModifier, peer);
285
+ const resolved = findCompatibleResolution(spec, fromNode, graph, peerContext, candidate, pendingDeps);
286
+ // a dist-tag satisfies any node of that name, so reuse is what
287
+ // installs an arbitrary version for an explicit `pkg@tag` request.
288
+ // drop the candidate and fetch the tag instead; a registry spec
289
+ // naming a workspace still links that workspace.
290
+ const dropForTag = explicitTag &&
291
+ !!resolved.existingNode &&
292
+ !resolved.existingNode.importer;
293
+ const existingNode = dropForTag ? undefined : resolved.existingNode;
294
+ const peerCompatResult = dropForTag ? { compatible: true } : resolved.peerCompatResult;
151
295
  // Accumulate fork request if incompatible peer edges detected (defer actual fork)
152
296
  const effectivePeerContext = peerContext;
153
- /* c8 ignore start */
154
297
  if (!peerCompatResult.compatible && peerCompatResult.forkEntry) {
155
298
  forkRequests.push(peerCompatResult.forkEntry);
156
299
  // All fork entries from this fromNode will be applied together in Phase B
157
300
  }
158
- /* c8 ignore stop */
159
- // defines what nodes are eligible to be reused
301
+ // Locked detached nodes still go through placePackage so
302
+ // their child edges are rebuilt after resetEdges().
160
303
  const validExistingNode = existingNode &&
161
304
  !existingNode.detached &&
162
305
  // Regular deps can always reuse.
@@ -194,21 +337,28 @@ const fetchManifestsForDeps = async (packageInfo, graph, fromNode, deps, scurry,
194
337
  // Start manifest fetch immediately for parallel processing
195
338
  const manifestPromise =
196
339
  // the "detached" node state means that it has already been load as
197
- // part of a graph (either lockfile or actual) and it has valid manifest
198
- // data so we shortcut the package info manifest fetch here
199
- existingNode?.detached && existingNode.manifest ?
340
+ // part of a graph (either lockfile or actual), or was hydrated with
341
+ // its locked version above, and it has valid manifest data so we
342
+ // shortcut the package info manifest fetch here
343
+ // `keepVersion` marks a fork that only differs by an optional peer
344
+ // link, so the copy keeps the version it forked from instead of
345
+ // re-resolving the range to the newest satisfying release
346
+ (existingNode?.manifest &&
347
+ (existingNode.detached || peerCompatResult.keepVersion)) ?
200
348
  Promise.resolve(existingNode.manifest)
201
349
  // this is the entry point to fetch calls to retrieve manifests
202
350
  // from the build ideal graph point of view
203
351
  : packageInfo
204
- .manifest(spec, { from: scurry.resolve(fromNode.location) })
352
+ .manifest(spec, { from })
205
353
  .then(manifest => manifest)
206
354
  .catch((er) => {
207
355
  // optional deps ignored if inaccessible, but a dep that tried
208
356
  // to escape the project is never silently dropped
209
357
  if (isPathSecurityError(er))
210
358
  throw er;
211
- if (edgeOptional || fromNode.optional) {
359
+ // an explicit request has no version to save if it never
360
+ // resolved, so it fails the install even when optional
361
+ if (!isExplicit && (edgeOptional || fromNode.optional)) {
212
362
  return undefined;
213
363
  }
214
364
  throw er;
@@ -324,7 +474,14 @@ const processPlacementTasks = async (graph, options, placementTasks, add, modifi
324
474
  !node.isOptional() &&
325
475
  // this fixes an issue with installing `file:pathname` specs
326
476
  /* c8 ignore next */ !fileTypeInfo?.isDirectory &&
327
- !node.importer;
477
+ !node.importer &&
478
+ // provisional peer suffixes are rewritten by canonicalizePeerIds,
479
+ // which moves the store dir into place. Skip when a dir for this
480
+ // package version already exists: the rewrite most likely lands
481
+ // back on it, and re-extracting would be pure waste.
482
+ (!node.peerSetHash ||
483
+ !!fileTypeInfo ||
484
+ !hasActualBase(actual, node.id));
328
485
  // extract the node if it meets the criteria for early extraction
329
486
  if (eligibleForExtraction) {
330
487
  /* c8 ignore start */
@@ -432,6 +589,12 @@ const processPlacementTasks = async (graph, options, placementTasks, add, modifi
432
589
  }
433
590
  }
434
591
  }
592
+ // a name declared both as a regular dependency (manifest or
593
+ // transient) and as a peer is the package's own dependency: placing
594
+ // it once, as the regular type, keeps the edge type independent of
595
+ // when the peer would have resolved
596
+ for (const dep of nextDeps)
597
+ nextPeerDeps.delete(dep.spec.name);
435
598
  // finish peer placement for this node, resolving satisfied peers
436
599
  // to seen nodes from the peer context and adding unsatisfied peers
437
600
  // to `nextDeps` so they get processed along regular dependencies
@@ -465,12 +628,15 @@ const processPlacementTasks = async (graph, options, placementTasks, add, modifi
465
628
  * **Early Extraction**: When `actual` graph is provided, nodes are extracted
466
629
  * to the vlt store during graph construction (not after), improving performance.
467
630
  */
468
- export const appendNodes = async (packageInfo, graph, fromNode, deps, scurry, options, seen, add, modifiers, modifierRefs, extractPromises, actual, seenExtracted, remover, transientAdd, transientRemove) => {
631
+ export const appendNodes = async (packageInfo, graph, fromNode, deps, scurry, options, seen, add, modifiers, modifierRefs, extractPromises, actual, seenExtracted, remover, transientAdd, transientRemove, explicit) => {
469
632
  // Cycle detection: skip if already processed
470
633
  /* c8 ignore next */
471
634
  if (seen.has(fromNode.id))
472
635
  return;
473
636
  seen.add(fromNode.id);
637
+ // locked lockfile nodes hydrated with their exact manifest, at most
638
+ // once per node across the parallel fetches
639
+ const hydrations = new Map();
474
640
  // PEER CONTEXT ISOLATION: Each workspace importer needs its own context
475
641
  // to prevent peer targets from one workspace affecting another.
476
642
  // The main importer (index 0) uses the initial context; others get fresh ones.
@@ -516,7 +682,7 @@ export const appendNodes = async (packageInfo, graph, fromNode, deps, scurry, op
516
682
  // Fetch manifests and collect tasks (no graph mutations)
517
683
  const result = await fetchManifestsForDeps(packageInfo, graph, node,
518
684
  // Sort by name for deterministic ordering (reproducible builds)
519
- nodeDeps.sort((a, b) => a.spec.name.localeCompare(b.spec.name, 'en')), scurry, peerContext, nodeModifierRefs, depth);
685
+ nodeDeps.sort((a, b) => a.spec.name.localeCompare(b.spec.name, 'en')), scurry, options, hydrations, peerContext, nodeModifierRefs, depth, explicit);
520
686
  return {
521
687
  entry: {
522
688
  node,
@@ -1,4 +1,6 @@
1
1
  import { getImporterSpecs } from "./get-importer-specs.js";
2
+ import { canonicalizePeerIds, hasProvisionalPeerIds, } from "./canonicalize-peer-ids.js";
3
+ import { movePeerStoreDirs } from "./move-peer-store-dirs.js";
2
4
  import { refreshIdealGraph } from "./refresh-ideal-graph.js";
3
5
  import { resolveSaveType } from "../resolve-save-type.js";
4
6
  /**
@@ -9,11 +11,21 @@ import { resolveSaveType } from "../resolve-save-type.js";
9
11
  * listed in the `add` and `remove` properties.
10
12
  */
11
13
  export const buildIdealFromStartingGraph = async (options) => {
14
+ // a graph loaded from a lockfile written by this code is canonical by
15
+ // construction; only a structural write during this build, or
16
+ // provisional ids left by an older lockfile, can change that
17
+ const mutationsBefore = options.graph.mutations;
12
18
  // Gets a map of dependencies that are keyed to its importer node ids,
13
19
  // merging values already found in the graph with user specified values.
14
20
  // Any dependencies that are already satisfied in the starting `graph`
15
21
  // are going to be pruned from the resulting object.
16
22
  const importerSpecs = getImporterSpecs(options);
23
+ // snapshot what the user actually asked for before the merge below
24
+ // folds manifest-derived deltas into `options.add`
25
+ const explicit = new Map();
26
+ for (const [id, deps] of options.add) {
27
+ explicit.set(id, new Set(deps.keys()));
28
+ }
17
29
  // merge modifiedDependencies flags
18
30
  options.add.modifiedDependencies =
19
31
  options.add.modifiedDependencies ||
@@ -56,14 +68,30 @@ export const buildIdealFromStartingGraph = async (options) => {
56
68
  options.remove.get(nodeId)?.add(depName);
57
69
  }
58
70
  }
71
+ // an importer edge whose lockfile spec or type no longer matches
72
+ // package.json while its target still satisfies it: rewrite both so
73
+ // the rebuild reads the manifest values and the lockfile is saved
74
+ // carrying them
75
+ for (const [edge, dep] of importerSpecs.staleSpecs) {
76
+ edge.spec = dep.spec;
77
+ edge.type = resolveSaveType(edge.from, edge.name, dep.type);
78
+ }
79
+ if (importerSpecs.staleSpecs.size) {
80
+ options.graph.lockfileStale = true;
81
+ }
59
82
  // refreshs the current graph adding the nodes marked for addition
60
83
  // and removing the ones marked for removal, while also recalculating
61
84
  // peer dependencies and default locations
62
85
  await refreshIdealGraph({
63
86
  ...options,
87
+ explicit,
64
88
  transientAdd: importerSpecs.transientAdd,
65
89
  transientRemove: importerSpecs.transientRemove,
66
90
  });
67
91
  options.graph.gc();
92
+ if (options.graph.mutations !== mutationsBefore ||
93
+ hasProvisionalPeerIds(options.graph)) {
94
+ await movePeerStoreDirs(canonicalizePeerIds(options.graph), options);
95
+ }
68
96
  return options.graph;
69
97
  };
@@ -0,0 +1,44 @@
1
+ import type { DepID } from '@vltpkg/dep-id';
2
+ import type { Graph } from '../graph.ts';
3
+ import type { Node } from '../node.ts';
4
+ export type CanonicalizePeerIdsOptions = {
5
+ digest?: (input: string, length: number) => string;
6
+ };
7
+ export type PeerStoreMove = {
8
+ node: Node;
9
+ from: DepID;
10
+ /** absent = merged-away copy, discard its dir */
11
+ to?: DepID;
12
+ };
13
+ /**
14
+ * Any change to this digest, to `serializeNodeEnv`, to `unitEntry` or to
15
+ * the suffix format changes every peer id in the lockfile, and the ideal
16
+ * builder no longer re-hashes a graph it did not otherwise touch. Bump
17
+ * `LOCKFILE_VERSION` alongside it so old lockfiles hard-fail instead of
18
+ * lingering with stale ids.
19
+ */
20
+ export declare const peerEnvDigest: (input: string, length?: number) => string;
21
+ /**
22
+ * `peer.<ordinal>` as minted by retrievePeerContextHash() during a
23
+ * rebuild, or written by lockfiles from before the hash format.
24
+ * Canonical suffixes are 16 or 32 hex chars, optionally followed by
25
+ * `.<role>` or `.x`; an all-digit hash is possible, hence the length
26
+ * bound rather than a plain `\d+`.
27
+ */
28
+ export declare const isProvisionalPeerSuffix: (suffix: string) => boolean;
29
+ /**
30
+ * True when any node still carries a provisional id, so the graph needs
31
+ * a canonicalization pass even if this build did not write to it.
32
+ */
33
+ export declare const hasProvisionalPeerIds: (graph: Graph) => boolean;
34
+ export declare const byteCompare: (a: string, b: string) => number;
35
+ export declare const isPeerScoped: (node: Node, graph: Graph) => boolean;
36
+ export declare const serializeNodeEnv: (node: Node, opts?: {
37
+ resolvedIds?: Map<Node, DepID>;
38
+ intraSccIndex?: Map<Node, number>;
39
+ }) => string;
40
+ /**
41
+ * Replace provisional / legacy `peer.<N>` DepID suffixes with a content
42
+ * hash of each node's resolved out-edge set. Called after `graph.gc()`.
43
+ */
44
+ export declare const canonicalizePeerIds: (graph: Graph, options?: CanonicalizePeerIdsOptions) => PeerStoreMove[];