@sous-io/sous 0.2.17 → 0.2.18

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.
@@ -71,6 +71,7 @@ import { describeIndexSearch } from "./ref-search.js";
71
71
  import {
72
72
  PROJECT_REQUESTER,
73
73
  resolveRefs,
74
+ type MissingRepo,
74
75
  type RefRequest,
75
76
  type ResolvedRecipe,
76
77
  type ResolverRepo,
@@ -112,8 +113,16 @@ import {
112
113
  findNewerInRange,
113
114
  recordUpstreamCheck,
114
115
  shouldCheckUpstream,
116
+ type NewerVersion,
115
117
  withDeadline,
116
118
  } from "./freshness.js";
119
+ import {
120
+ describeUpdateScope,
121
+ formatUpdatePlan,
122
+ isEmptyUpdate,
123
+ recipeInScope,
124
+ type UpdateScope,
125
+ } from "./update-plan.js";
117
126
  import { REPO_NAME_PATTERN } from "./formats/patterns.js";
118
127
  import {
119
128
  linkedPathFor,
@@ -123,14 +132,20 @@ import {
123
132
  } from "./links.js";
124
133
  import {
125
134
  keysHeldBySubscription,
135
+ lockedClosure,
126
136
  listLockedRecipes,
127
137
  mapLinkedRecipes,
128
138
  readRecipeManifestIn,
129
139
  } from "./locked-recipes.js";
130
140
  import { resolveStoreRoot } from "../sous-home.js";
131
- import { seedCoreRecipe, type SeedCoreRecipeReport } from "./seed.js";
141
+ import {
142
+ packagedCoreIndexOverlay,
143
+ seedCoreRecipe,
144
+ type SeedCoreRecipeReport,
145
+ } from "./seed.js";
132
146
  import { enabledRepos, enabledSubscriptions, isBuiltInEntry } from "./defaults.js";
133
147
  import {
148
+ CORE_NAMESPACE,
134
149
  CORE_RECIPE_KEY,
135
150
  OFFICIAL_REPO_IDENTITY,
136
151
  packagedCoreRecipeDir,
@@ -403,6 +418,69 @@ export type UpstreamCheckReport = {
403
418
  unchecked: Array<{ repo: string; reason: string }>;
404
419
  };
405
420
 
421
+ /** What `update` is asked to do. */
422
+ export type UpdateOptions = {
423
+ /**
424
+ * What to update: a repository, a namespace or a recipe, written at any level
425
+ * of qualification and resolved through `src/lib/refs/`. Left out, every
426
+ * subscription is updated.
427
+ */
428
+ ref?: string;
429
+ /**
430
+ * A repository short name the caller has already settled, which narrows the
431
+ * update exactly as a reference naming that repository would. `sous repo
432
+ * unlink --update` passes this; it is never combined with `ref`.
433
+ */
434
+ repo?: string;
435
+ /** Accept the plan, and trust any repository a newer version needs, without being asked. */
436
+ yes?: boolean;
437
+ /** Take the first candidate when the reference matched several things. */
438
+ acceptFirst?: boolean;
439
+ /** Answers supplied ahead of the questions the new versions ask. */
440
+ answers?: ProvidedAnswer[];
441
+ /** Work out and print the plan, writing nothing and downloading no recipe. */
442
+ dryRun?: boolean;
443
+ };
444
+
445
+ /** What `update` did, or would do on a dry run. */
446
+ export type UpdateOutcome = {
447
+ /** What the update covered. */
448
+ scope: UpdateScope;
449
+ /** What changed in the lockfile. */
450
+ diff: LockDiff;
451
+ /** True when nothing needed moving, so nothing was asked and nothing written. */
452
+ nothingToUpdate: boolean;
453
+ /** Repositories whose index could not be fetched; their pins stayed. */
454
+ unreachable: Array<{ repo: string; reason: string }>;
455
+ /** Subscriptions that could not be resolved; their pins stayed. */
456
+ failed: Array<{ key: string; reason: string }>;
457
+ /** Linked repositories whose pins moved. */
458
+ linked: string[];
459
+ /** Repositories trusted along the way. */
460
+ trusted: string[];
461
+ /** What the variable questions produced, when any were asked. */
462
+ answers?: AskReport;
463
+ /** Dependency cycles the resolver noticed. */
464
+ cycles: string[][];
465
+ /** True when nothing was written, because this was a dry run. */
466
+ dryRun: boolean;
467
+ };
468
+
469
+ /** What looking for newer published versions of one repository's pins found. */
470
+ export type NewerVersionsReport =
471
+ | {
472
+ /** The index was fetched and compared. */
473
+ checked: true;
474
+ /** Every pin with a newer published version its range allows, by key. */
475
+ newer: NewerVersion[];
476
+ }
477
+ | {
478
+ /** The index could not be fetched in time, so nothing is known. */
479
+ checked: false;
480
+ /** Why not, as a sentence. */
481
+ reason: string;
482
+ };
483
+
406
484
  // --- The service --------------------------------------------------------------------------------
407
485
 
408
486
  /** Adds repositories, subscribes to recipes, and keeps the store and lockfile honest. */
@@ -511,6 +589,11 @@ export class SubscriptionService {
511
589
  providerOptions: this.providerOptions,
512
590
  warn: this.warn,
513
591
  now: this.now,
592
+ // Every cache a command builds knows the packaged core version from the
593
+ // start, so a command that never seeds (a lockfile rebuild, a browsing
594
+ // command) resolves and lists exactly what a build does. Seeding
595
+ // replaces this with an overlay carrying the seeded entry's own hash.
596
+ overlay: packagedCoreIndexOverlay({ version: SOUS_VERSION, warn: this.warn }),
514
597
  });
515
598
  this.trust =
516
599
  options.trust ??
@@ -1586,6 +1669,664 @@ export class SubscriptionService {
1586
1669
  return lines;
1587
1670
  }
1588
1671
 
1672
+ // --- Moving pins within their ranges -----------------------------------------------------------
1673
+
1674
+ /**
1675
+ * Moves the lockfile's pins to the newest published versions their ranges
1676
+ * allow, and nothing else: the subscriptions themselves are never edited, and
1677
+ * no range is ever widened.
1678
+ *
1679
+ * The order is the design, as it is for subscribing:
1680
+ *
1681
+ * - Every trusted repository's index is fetched fresh first. One that
1682
+ * cannot be reached is reported, and its pins stay exactly where they are.
1683
+ * - The reference, when there is one, is resolved through `src/lib/refs/`
1684
+ * against those indexes, and it narrows which pins may move. Everything
1685
+ * outside it is held at its locked version for as long as its ranges
1686
+ * allow, so dependencies move with the closure and nothing else does.
1687
+ * - The whole change is printed as a plan and asked about once. With
1688
+ * nothing to change, nothing is asked. `--yes` accepts it; a run with no
1689
+ * terminal and no `--yes` fails naming the flag.
1690
+ * - Any repository a newer version needs that the project does not trust
1691
+ * goes through the usual trust question, and the plan is worked out again
1692
+ * once it is trusted.
1693
+ * - The lockfile is written, and then the questions the new versions ask
1694
+ * are asked, exactly as `subscribe` asks them.
1695
+ *
1696
+ * A subscription sous provides itself (the `core` namespace) never moves: its
1697
+ * range is exactly the running sous version. Switched-off subscriptions take
1698
+ * no part. A linked repository's pins move like any other, and builds keep
1699
+ * reading its checkout until it is unlinked.
1700
+ *
1701
+ * @param options - What to update, and the confirmation, answer and dry-run flags.
1702
+ */
1703
+ async update(options: UpdateOptions = {}): Promise<UpdateOutcome> {
1704
+ const dryRun = options.dryRun === true;
1705
+
1706
+ const repoNames = Object.keys(this.currentRepos()).sort();
1707
+ const { indexes, unreachable } = await this.fetchFreshIndexes(repoNames);
1708
+ const scope = await this.resolveUpdateScope(options, indexes);
1709
+ const unreachableNames = new Set(unreachable.map((entry) => entry.repo));
1710
+
1711
+ let plan = await this.planUpdate(scope, indexes, unreachableNames, dryRun);
1712
+ const facts = {
1713
+ scope,
1714
+ unreachable,
1715
+ builtIn: this.builtInSubscriptionsIn(scope),
1716
+ switchedOff: this.switchedOffSubscriptionsIn(scope),
1717
+ };
1718
+
1719
+ const questions = planQuestions(this.definedVariables(plan.changed), this.ladderContext(), {
1720
+ sousDir: this.sousDir,
1721
+ }).filter((entry) => !entry.answered);
1722
+
1723
+ for (const line of formatUpdatePlan({
1724
+ ...facts,
1725
+ diff: plan.diff,
1726
+ missingRepos: plan.missingRepos,
1727
+ questions,
1728
+ unreadable: dryRun ? this.unreadableRecipes(plan.changed) : [],
1729
+ linked: plan.linked,
1730
+ failed: plan.failed,
1731
+ })) {
1732
+ this.write(line === "" ? "" : indent(line));
1733
+ }
1734
+
1735
+ const outcome = (trusted: string[], answers?: AskReport): UpdateOutcome => ({
1736
+ scope,
1737
+ diff: plan.diff,
1738
+ nothingToUpdate: isEmptyUpdate(plan),
1739
+ unreachable,
1740
+ failed: plan.failed,
1741
+ linked: plan.linked,
1742
+ trusted,
1743
+ ...(answers === undefined ? {} : { answers }),
1744
+ cycles: plan.cycles,
1745
+ dryRun,
1746
+ });
1747
+
1748
+ if (isEmptyUpdate(plan) || dryRun) return outcome([]);
1749
+
1750
+ if (options.yes !== true) {
1751
+ if (!this.interactive) {
1752
+ throw nonInteractiveError({
1753
+ prompt: `whether to update ${describeUpdateScope(scope)}`,
1754
+ remedy:
1755
+ "pass '--yes' (spelled '-y', '--force' or '--trust' if you prefer) to accept " +
1756
+ "the plan above without being asked.",
1757
+ });
1758
+ }
1759
+
1760
+ const proceed = await this.ask("Update the lockfile?");
1761
+ if (!proceed) {
1762
+ throw new ConfigError(
1763
+ `Nothing was written: the update was declined.\n` +
1764
+ ` The lockfile and this project's config are exactly as they were.`
1765
+ );
1766
+ }
1767
+ }
1768
+
1769
+ // A newer version that needs a repository this project does not trust is a
1770
+ // trust decision, asked by name exactly as subscribing asks it. Once it is
1771
+ // trusted, its index is read and the plan is worked out again, since the
1772
+ // closure could not be walked past it before.
1773
+ const trusted: string[] = [];
1774
+ while (plan.missingRepos.length > 0) {
1775
+ const decided = await this.trust.confirmTrust(plan.missingRepos, {
1776
+ interactive: this.interactive,
1777
+ ...(options.yes === undefined ? {} : { trustFlag: options.yes }),
1778
+ });
1779
+
1780
+ if (decided.needUrl.length > 0) {
1781
+ throw new ConfigError(
1782
+ [
1783
+ `Sous does not know where ${
1784
+ decided.needUrl.length === 1
1785
+ ? `the repository '${decided.needUrl[0]}' lives`
1786
+ : `these repositories live: ${decided.needUrl.map((n) => `'${n}'`).join(", ")}`
1787
+ }, so it cannot add ${decided.needUrl.length === 1 ? "it" : "them"} for you.`,
1788
+ " Nothing was written. Add each one with its URL, then run this command again:",
1789
+ "",
1790
+ ...decided.needUrl.map((name) => ` sous repo add <url> --name ${name}`),
1791
+ ].join("\n")
1792
+ );
1793
+ }
1794
+
1795
+ trusted.push(...decided.added);
1796
+ const added = await this.fetchFreshIndexes(decided.added);
1797
+ for (const [name, index] of added.indexes) indexes.set(name, index);
1798
+ plan = await this.planUpdate(scope, indexes, unreachableNames, false);
1799
+ }
1800
+
1801
+ // Supplied answers are checked before anything is written, so one that does
1802
+ // not fit fails the run rather than leaving the lockfile moved with its
1803
+ // questions unanswered.
1804
+ validateProvidedAnswers(this.definedVariables(plan.changed), options.answers ?? []);
1805
+
1806
+ for (const recipe of plan.changed) await this.ensureStored(recipe);
1807
+ this.lock.write(plan.after);
1808
+
1809
+ const answers = await this.askVariables(plan.changed, options.answers ?? []);
1810
+ return outcome(trusted, answers);
1811
+ }
1812
+
1813
+ /**
1814
+ * Fetches the index of each named repository straight from upstream,
1815
+ * ignoring the freshness window. A repository that cannot be reached is
1816
+ * reported rather than raised, and the copy already cached (when there is
1817
+ * one) stands in for it so its pins can still be held where they are.
1818
+ *
1819
+ * @param names - The repositories' short names.
1820
+ */
1821
+ private async fetchFreshIndexes(names: string[]): Promise<{
1822
+ indexes: Map<string, IndexFile>;
1823
+ unreachable: Array<{ repo: string; reason: string }>;
1824
+ }> {
1825
+ const repos = this.currentRepos();
1826
+ const indexes = new Map<string, IndexFile>();
1827
+ const unreachable: Array<{ repo: string; reason: string }> = [];
1828
+
1829
+ for (const name of names) {
1830
+ const entry = repos[name];
1831
+ if (entry === undefined) continue;
1832
+
1833
+ let identity: string;
1834
+ try {
1835
+ identity = this.identityOf(entry.url, entry.provider);
1836
+ } catch (error) {
1837
+ unreachable.push({ repo: name, reason: describeError(error) });
1838
+ continue;
1839
+ }
1840
+
1841
+ try {
1842
+ const lookup = await this.indexCache.refresh(identity, {
1843
+ url: entry.url,
1844
+ label: name,
1845
+ ...(entry.provider === undefined ? {} : { provider: entry.provider }),
1846
+ });
1847
+ indexes.set(name, lookup.index);
1848
+ } catch (error) {
1849
+ unreachable.push({ repo: name, reason: describeError(error) });
1850
+ const cached = this.indexCache.readCached(identity);
1851
+ if (cached !== undefined) indexes.set(name, cached);
1852
+ }
1853
+ }
1854
+
1855
+ return { indexes, unreachable };
1856
+ }
1857
+
1858
+ /**
1859
+ * Settles what an update covers: a repository the caller named outright, the
1860
+ * repository, namespace or recipe a reference names, or everything.
1861
+ *
1862
+ * @param options - The reference or repository, and the accept-first flag.
1863
+ * @param indexes - The freshly fetched indexes, which the reference is resolved against.
1864
+ */
1865
+ private async resolveUpdateScope(
1866
+ options: UpdateOptions,
1867
+ indexes: Map<string, IndexFile>
1868
+ ): Promise<UpdateScope> {
1869
+ if (options.repo !== undefined) {
1870
+ this.repoSearchOrder(options.repo);
1871
+ return { kind: "repository", repo: options.repo };
1872
+ }
1873
+ if (options.ref === undefined) return { kind: "all" };
1874
+
1875
+ const repoOrder = this.repoSearchOrder();
1876
+ const repos = this.currentRepos();
1877
+ const urls: Record<string, string | undefined> = {};
1878
+ for (const name of repoOrder) urls[name] = repos[name]?.url;
1879
+
1880
+ const matches = findReference(
1881
+ options.ref,
1882
+ [SousScope.Repository, SousScope.Namespace, SousScope.Recipe],
1883
+ { repos: referenceReposFromIndexes(repoOrder, indexes, urls) }
1884
+ );
1885
+
1886
+ const chosen = await pickReference(matches, {
1887
+ search: options.ref,
1888
+ interactive: this.interactive,
1889
+ ...(options.acceptFirst === undefined ? {} : { acceptFirst: options.acceptFirst }),
1890
+ details: [
1891
+ ` No trusted repository, namespace or recipe has that name.`,
1892
+ ...describeIndexSearch({ name: options.ref, repoOrder, indexes }),
1893
+ ],
1894
+ write: (message: string) => this.write(message),
1895
+ choose: (message, offered) => this.choose(message, offered),
1896
+ });
1897
+
1898
+ switch (chosen.scope) {
1899
+ case SousScope.Repository:
1900
+ return { kind: "repository", repo: chosen.repo! };
1901
+ case SousScope.Namespace:
1902
+ return { kind: "namespace", repo: chosen.repo!, namespace: chosen.namespace! };
1903
+ default:
1904
+ return {
1905
+ kind: "recipe",
1906
+ repo: chosen.repo!,
1907
+ key: `${chosen.namespace!}/${chosen.recipe!}`,
1908
+ };
1909
+ }
1910
+ }
1911
+
1912
+ /**
1913
+ * Works out what an update would do, writing nothing.
1914
+ *
1915
+ * Every subscription the scope touches is resolved afresh against the indexes
1916
+ * given, one at a time so one that cannot be resolved does not stop the
1917
+ * others, with every pin outside the scope held where the lockfile has it.
1918
+ * What the resolutions settle on is laid over the lockfile, and every hold the
1919
+ * new closure no longer declares is released, refcounted, so a dependency a
1920
+ * newer version dropped leaves the lockfile unless something else still holds
1921
+ * it.
1922
+ *
1923
+ * @param scope - What may move.
1924
+ * @param indexes - The indexes to resolve against.
1925
+ * @param unreachable - Repositories whose pins must not move, because their
1926
+ * index could not be fetched.
1927
+ * @param dryRun - When true, read manifests only from disk and download nothing.
1928
+ */
1929
+ private async planUpdate(
1930
+ scope: UpdateScope,
1931
+ indexes: Map<string, IndexFile>,
1932
+ unreachable: Set<string>,
1933
+ dryRun: boolean
1934
+ ): Promise<{
1935
+ after: Lockfile;
1936
+ diff: LockDiff;
1937
+ changed: ResolvedRecipe[];
1938
+ missingRepos: MissingRepo[];
1939
+ failed: Array<{ key: string; reason: string }>;
1940
+ linked: string[];
1941
+ cycles: string[][];
1942
+ }> {
1943
+ const before = this.lock.read();
1944
+ const subscriptions = this.allSubscriptions();
1945
+ const repos = this.resolverRepos();
1946
+
1947
+ /** True when the update may move this recipe. */
1948
+ const movable = (key: string, repo: string): boolean => {
1949
+ if (unreachable.has(repo)) return false;
1950
+ if (key === CORE_RECIPE_KEY && this.isBuiltInSubscription(CORE_NAMESPACE)) {
1951
+ return false;
1952
+ }
1953
+ return recipeInScope(scope, key, repo);
1954
+ };
1955
+
1956
+ // Everything the update may not move is held where the lockfile has it.
1957
+ const keep: Record<string, string> = {};
1958
+ for (const [key, entry] of Object.entries(before.recipes)) {
1959
+ if (!movable(key, entry.repo)) keep[key] = entry.version;
1960
+ }
1961
+
1962
+ const targets = Object.keys(subscriptions)
1963
+ .sort()
1964
+ .filter((key) => !this.isBuiltInSubscription(key))
1965
+ .filter((key) => this.subscriptionTouches(key, scope, before, movable));
1966
+
1967
+ const merged = new Map<string, ResolvedRecipe>();
1968
+ const resolvedSubscriptions: string[] = [];
1969
+ const missing = new Map<string, MissingRepo>();
1970
+ const failed: Array<{ key: string; reason: string }> = [];
1971
+ const cycles: string[][] = [];
1972
+
1973
+ for (const key of targets) {
1974
+ const entry = subscriptions[key]!;
1975
+ let request: RefRequest;
1976
+ try {
1977
+ const parsed = parseRef(key);
1978
+ request = {
1979
+ ref: { ...parsed, ...(entry.range === undefined ? {} : { range: entry.range }) },
1980
+ requestedBy: PROJECT_REQUESTER,
1981
+ kind: "subscribes",
1982
+ ...(entry.prerelease === true ? { prerelease: true } : {}),
1983
+ };
1984
+ } catch (error) {
1985
+ failed.push({ key, reason: describeError(error) });
1986
+ continue;
1987
+ }
1988
+
1989
+ let result;
1990
+ try {
1991
+ result = await resolveRefs([request], {
1992
+ indexes,
1993
+ repos,
1994
+ keep,
1995
+ loadManifest: (recipe) => this.loadRecipeManifest(recipe, dryRun),
1996
+ ...(entry.prerelease === true ? { prerelease: true } : {}),
1997
+ });
1998
+ } catch (error) {
1999
+ failed.push({ key, reason: describeError(error) });
2000
+ continue;
2001
+ }
2002
+
2003
+ cycles.push(...result.cycles);
2004
+ for (const repo of result.missingRepos) {
2005
+ const known = missing.get(repo.name);
2006
+ if (known === undefined) missing.set(repo.name, { ...repo });
2007
+ else known.requiredBy.push(...repo.requiredBy);
2008
+ }
2009
+
2010
+ let conflict = false;
2011
+ for (const recipe of result.resolved) {
2012
+ const already = merged.get(recipe.key);
2013
+ if (already !== undefined && already.version !== recipe.version) {
2014
+ failed.push({
2015
+ key,
2016
+ reason:
2017
+ `It wants version ${recipe.version} of '${recipe.key}', and another ` +
2018
+ `subscription wants ${already.version}.`,
2019
+ });
2020
+ conflict = true;
2021
+ break;
2022
+ }
2023
+ }
2024
+ if (conflict) continue;
2025
+
2026
+ for (const recipe of result.resolved) {
2027
+ const already = merged.get(recipe.key);
2028
+ merged.set(recipe.key, already === undefined ? recipe : mergeHolders(already, recipe));
2029
+ }
2030
+ resolvedSubscriptions.push(key);
2031
+ }
2032
+
2033
+ const settled = [...merged.values()];
2034
+ const applied = this.lock.applyResolution(before, settled, this.lockRepoInputs());
2035
+ const others = Object.keys(subscriptions).filter(
2036
+ (key) => !resolvedSubscriptions.includes(key)
2037
+ );
2038
+ const after = this.releaseDroppedHolds(applied, merged, resolvedSubscriptions, others);
2039
+ const diff = this.lock.diff(before, after);
2040
+
2041
+ const changed = settled.filter(
2042
+ (recipe) => before.recipes[recipe.key]?.version !== recipe.version
2043
+ );
2044
+ const linked = [
2045
+ ...new Set(
2046
+ changed
2047
+ .map((recipe) => recipe.repo)
2048
+ .filter((repo) => linkedPathFor(repo, this.sousDir, this.env) !== undefined)
2049
+ ),
2050
+ ].sort();
2051
+
2052
+ return {
2053
+ after,
2054
+ diff,
2055
+ changed,
2056
+ missingRepos: [...missing.values()].sort((left, right) =>
2057
+ left.name < right.name ? -1 : 1
2058
+ ),
2059
+ failed,
2060
+ linked,
2061
+ cycles,
2062
+ };
2063
+ }
2064
+
2065
+ /**
2066
+ * True when a subscription reaches anything the update may move, which is
2067
+ * what decides whether it is resolved again. A subscription with nothing
2068
+ * locked yet counts when its own ref falls inside the scope.
2069
+ *
2070
+ * @param key - The subscription's ref key.
2071
+ * @param scope - What the update covers.
2072
+ * @param lock - The lockfile as it stands.
2073
+ * @param movable - Whether one locked recipe may move.
2074
+ */
2075
+ private subscriptionTouches(
2076
+ key: string,
2077
+ scope: UpdateScope,
2078
+ lock: Lockfile,
2079
+ movable: (key: string, repo: string) => boolean
2080
+ ): boolean {
2081
+ if (scope.kind === "all") return true;
2082
+
2083
+ let parsed: ParsedRef;
2084
+ try {
2085
+ parsed = parseRef(key);
2086
+ } catch {
2087
+ return false;
2088
+ }
2089
+
2090
+ const closure = lockedClosure(lock, keysHeldBySubscription(lock, refKey(parsed)));
2091
+ if (closure.some((held) => movable(held, lock.recipes[held]!.repo))) return true;
2092
+
2093
+ // Nothing locked reaches the scope, but the subscription may still name it:
2094
+ // a namespace subscription gains the recipes a namespace publishes later.
2095
+ if (parsed.repo !== undefined && parsed.repo !== scope.repo) return false;
2096
+ const covered = refKey(parsed);
2097
+ switch (scope.kind) {
2098
+ case "repository":
2099
+ return parsed.repo === scope.repo;
2100
+ case "namespace":
2101
+ return covered === scope.namespace || covered.startsWith(`${scope.namespace}/`);
2102
+ case "recipe":
2103
+ return covered === scope.key || scope.key.startsWith(`${covered}/`);
2104
+ }
2105
+ }
2106
+
2107
+ /**
2108
+ * Releases every hold the new resolution no longer declares.
2109
+ *
2110
+ * Laying a resolution over the lockfile only ever adds holders, which is right
2111
+ * for a subscription and wrong for an update: a newer version that no longer
2112
+ * depends on something must stop holding it, and a namespace that stopped
2113
+ * publishing a recipe must stop holding that one. A hold is released when the
2114
+ * holder was resolved again and did not declare the recipe this time; an entry
2115
+ * left with no holders goes, and so does whatever only it was holding.
2116
+ *
2117
+ * @param lock - The lockfile with the resolution laid over it.
2118
+ * @param merged - What the resolutions settled on, by key.
2119
+ * @param resolvedSubscriptions - The subscriptions that were resolved again.
2120
+ * @param otherSubscriptions - Subscriptions that were not, whose holds stand.
2121
+ */
2122
+ private releaseDroppedHolds(
2123
+ lock: Lockfile,
2124
+ merged: Map<string, ResolvedRecipe>,
2125
+ resolvedSubscriptions: string[],
2126
+ otherSubscriptions: string[]
2127
+ ): Lockfile {
2128
+ const coverOf = (key: string): string | undefined => {
2129
+ try {
2130
+ return refKey(parseRef(key));
2131
+ } catch {
2132
+ return undefined;
2133
+ }
2134
+ };
2135
+ const covers = (cover: string | undefined, key: string): boolean =>
2136
+ cover !== undefined && (key === cover || key.startsWith(`${cover}/`));
2137
+ const resolvedCovers = resolvedSubscriptions.map(coverOf);
2138
+ const otherCovers = otherSubscriptions.map(coverOf);
2139
+
2140
+ const recipes: Record<string, LockedRecipe> = { ...lock.recipes };
2141
+ const orphaned: string[] = [];
2142
+
2143
+ for (const [key, entry] of Object.entries(lock.recipes)) {
2144
+ const settled = merged.get(key);
2145
+ const kept = entry.requestedBy.filter((holder) => {
2146
+ if (holder === PROJECT_HOLDER) {
2147
+ if (!resolvedCovers.some((cover) => covers(cover, key))) return true;
2148
+ if (otherCovers.some((cover) => covers(cover, key))) return true;
2149
+ return settled?.requestedBy.includes(PROJECT_HOLDER) === true;
2150
+ }
2151
+ if (!merged.has(holder)) return true;
2152
+ return settled?.requestedBy.includes(holder) === true;
2153
+ });
2154
+
2155
+ if (kept.length === entry.requestedBy.length) continue;
2156
+ if (kept.length === 0) {
2157
+ delete recipes[key];
2158
+ orphaned.push(key);
2159
+ } else {
2160
+ recipes[key] = { ...entry, requestedBy: kept };
2161
+ }
2162
+ }
2163
+
2164
+ let result: Lockfile = { ...lock, recipes };
2165
+ for (const key of orphaned) result = this.lock.removeHolder(result, key);
2166
+ return result;
2167
+ }
2168
+
2169
+ /**
2170
+ * True when a subscription is one sous provides itself, which an update never
2171
+ * moves: its range is exactly the running sous version.
2172
+ *
2173
+ * @param key - The subscription's ref key.
2174
+ */
2175
+ private isBuiltInSubscription(key: string): boolean {
2176
+ const configured = enabledSubscriptions(this.settings) as Record<string, SubscriptionEntry>;
2177
+ return isBuiltInEntry(configured[key]);
2178
+ }
2179
+
2180
+ /**
2181
+ * The subscriptions sous provides itself that an update of this scope would
2182
+ * otherwise have covered, with the version each stays at, for the plan.
2183
+ *
2184
+ * @param scope - What the update covers.
2185
+ */
2186
+ private builtInSubscriptionsIn(
2187
+ scope: UpdateScope
2188
+ ): Array<{ key: string; version?: string }> {
2189
+ const lock = this.lock.read();
2190
+ return Object.keys(this.allSubscriptions())
2191
+ .filter((key) => this.isBuiltInSubscription(key))
2192
+ .filter((key) =>
2193
+ keysHeldBySubscription(lock, key).some((held) =>
2194
+ recipeInScope(scope, held, lock.recipes[held]!.repo)
2195
+ )
2196
+ )
2197
+ .map((key) => {
2198
+ const version = this.allSubscriptions()[key]?.range;
2199
+ return { key, ...(version === undefined ? {} : { version }) };
2200
+ });
2201
+ }
2202
+
2203
+ /**
2204
+ * The switched-off subscriptions an update of this scope leaves alone.
2205
+ *
2206
+ * @param scope - What the update covers.
2207
+ */
2208
+ private switchedOffSubscriptionsIn(scope: UpdateScope): string[] {
2209
+ const declared: Record<string, SubscriptionEntry> = {
2210
+ ...((this.settings.subscriptions ?? {}) as Record<string, SubscriptionEntry>),
2211
+ ...this.readSubscriptionEntries(),
2212
+ };
2213
+
2214
+ return Object.keys(declared)
2215
+ .filter((key) => declared[key]?.enabled === false)
2216
+ .filter((key) => {
2217
+ if (scope.kind === "all") return true;
2218
+ let parsed: ParsedRef;
2219
+ try {
2220
+ parsed = parseRef(key);
2221
+ } catch {
2222
+ return false;
2223
+ }
2224
+ const covered = refKey(parsed);
2225
+ switch (scope.kind) {
2226
+ case "repository":
2227
+ return parsed.repo === scope.repo;
2228
+ case "namespace":
2229
+ return covered === scope.namespace || covered.startsWith(`${scope.namespace}/`);
2230
+ case "recipe":
2231
+ return covered === scope.key || scope.key.startsWith(`${covered}/`);
2232
+ }
2233
+ })
2234
+ .sort();
2235
+ }
2236
+
2237
+ /**
2238
+ * Fetches one repository's index with a short timeout and says which of its
2239
+ * pins have a newer published version their ranges allow. Nothing moves; this
2240
+ * is the fact `sous repo unlink` reports. A fetch that fails or takes too long
2241
+ * is an answer too: that sous could not check.
2242
+ *
2243
+ * @param name - The repository's short name.
2244
+ * @param options - How long to wait for the index, in milliseconds.
2245
+ */
2246
+ async newerPublishedVersions(
2247
+ name: string,
2248
+ options: { timeoutMs?: number } = {}
2249
+ ): Promise<NewerVersionsReport> {
2250
+ const timeoutMs = options.timeoutMs ?? NEWER_VERSION_CHECK_TIMEOUT_MS;
2251
+ const lock = this.lock.read();
2252
+ const entry = this.currentRepos()[name];
2253
+ const url = entry?.url ?? lock.repos[name]?.url;
2254
+ const identity = this.identityForRepo(name, lock);
2255
+ if (url === undefined || identity === undefined) {
2256
+ return {
2257
+ checked: false,
2258
+ reason: `This project does not trust a repository called '${name}'.`,
2259
+ };
2260
+ }
2261
+
2262
+ // The same deadline and the same comparison a build's newer-version report
2263
+ // uses, so the two can never disagree about what counts as newer.
2264
+ let index: IndexFile;
2265
+ try {
2266
+ index = (
2267
+ await withDeadline(
2268
+ (signal) =>
2269
+ this.indexCache.refresh(identity, {
2270
+ url,
2271
+ label: name,
2272
+ ...(entry?.provider === undefined ? {} : { provider: entry.provider }),
2273
+ signal,
2274
+ }),
2275
+ timeoutMs
2276
+ )
2277
+ ).index;
2278
+ } catch (error) {
2279
+ return { checked: false, reason: describeError(error) };
2280
+ } finally {
2281
+ recordUpstreamCheck(this.indexCache, identity, this.now());
2282
+ }
2283
+
2284
+ return {
2285
+ checked: true,
2286
+ newer: this.newerInRange(index, name, lock, this.allSubscriptions()),
2287
+ };
2288
+ }
2289
+
2290
+ /**
2291
+ * Every recipe the lockfile pins from one repository that has a newer
2292
+ * published version inside the range its holders declared, sorted by key.
2293
+ * Ranges come from `effectiveRangeForHolders`, so nothing is ever reported
2294
+ * past a constraint a dependency declared. A build's newer-version report and
2295
+ * `sous repo unlink` both read it.
2296
+ *
2297
+ * @param index - The repository's index.
2298
+ * @param repoName - The repository's short name.
2299
+ * @param lock - The lockfile as it stands.
2300
+ * @param subscriptions - Every subscription, for ranges and prerelease opt-ins.
2301
+ */
2302
+ private newerInRange(
2303
+ index: IndexFile,
2304
+ repoName: string,
2305
+ lock: Lockfile,
2306
+ subscriptions: Record<string, SubscriptionEntry>
2307
+ ): NewerVersion[] {
2308
+ const newer: NewerVersion[] = [];
2309
+ for (const key of Object.keys(lock.recipes).sort()) {
2310
+ const entry = lock.recipes[key]!;
2311
+ if (entry.repo !== repoName) continue;
2312
+ const range = this.effectiveRangeFor(key, entry, subscriptions);
2313
+ if (range === undefined) continue;
2314
+ const subscription = subscriptions[key] ?? subscriptions[key.split("/")[0]!];
2315
+
2316
+ const found = findNewerInRange({
2317
+ index,
2318
+ key,
2319
+ lockedVersion: entry.version,
2320
+ ...(range === "*" ? {} : { range }),
2321
+ ...(subscription?.prerelease === undefined
2322
+ ? {}
2323
+ : { prerelease: subscription.prerelease }),
2324
+ });
2325
+ if (found !== undefined) newer.push(found);
2326
+ }
2327
+ return newer;
2328
+ }
2329
+
1589
2330
  // --- Restoring and upstream checks -------------------------------------------------------------
1590
2331
 
1591
2332
  /**
@@ -2026,25 +2767,8 @@ export class SubscriptionService {
2026
2767
  index ??= this.indexCache.readCached(identity);
2027
2768
  if (index === undefined) return;
2028
2769
 
2029
- for (const [key, entry] of Object.entries(lock.recipes).sort(([a], [b]) =>
2030
- a < b ? -1 : a > b ? 1 : 0
2031
- )) {
2032
- if (entry.repo !== repoName) continue;
2033
- const range = this.effectiveRangeFor(key, entry, input.subscriptions);
2034
- if (range === undefined) continue;
2035
- const subscription =
2036
- input.subscriptions[key] ?? input.subscriptions[key.split("/")[0]!];
2037
-
2038
- const newer = findNewerInRange({
2039
- index,
2040
- key,
2041
- lockedVersion: entry.version,
2042
- ...(range === "*" ? {} : { range }),
2043
- ...(subscription?.prerelease === undefined
2044
- ? {}
2045
- : { prerelease: subscription.prerelease }),
2046
- });
2047
- if (newer !== undefined) report.newer.push({ ...newer, repo: repoName });
2770
+ for (const newer of this.newerInRange(index, repoName, lock, input.subscriptions)) {
2771
+ report.newer.push({ ...newer, repo: repoName });
2048
2772
  }
2049
2773
  }
2050
2774