@heroku/sdk 0.7.1 → 0.8.1

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 (26) hide show
  1. package/dist/resources/data/backup/capture-and-wait.d.ts +2 -2
  2. package/dist/resources/data/internal/postgres-utils.d.ts +10 -0
  3. package/dist/resources/data/internal/postgres-utils.js +17 -0
  4. package/dist/resources/data/internal/resolve-pg-database.d.ts +38 -14
  5. package/dist/resources/data/internal/resolve-pg-database.js +51 -37
  6. package/dist/resources/data/restore/restore-and-wait.d.ts +2 -2
  7. package/dist/resources/data/transfer-schedule.d.ts +2 -2
  8. package/dist/resources/data/transfer-schedule.js +14 -5
  9. package/dist/resources/extensions/platform.d.ts +1 -0
  10. package/dist/resources/extensions/platform.js +1 -0
  11. package/dist/resources/platform/add-on/describe.d.ts +0 -5
  12. package/dist/resources/platform/add-on/describe.js +0 -5
  13. package/dist/resources/platform/add-on/index.d.ts +2 -4
  14. package/dist/resources/platform/add-on/index.js +2 -4
  15. package/dist/resources/platform/add-on/list-plans.d.ts +1 -4
  16. package/dist/resources/platform/add-on/list-plans.js +1 -4
  17. package/dist/resources/platform/add-on/resolve.d.ts +10 -38
  18. package/dist/resources/platform/add-on/resolve.js +21 -87
  19. package/dist/resources/platform/add-on/types.d.ts +8 -22
  20. package/dist/resources/platform/add-on/upgrade.d.ts +2 -3
  21. package/dist/resources/platform/add-on/upgrade.js +2 -3
  22. package/dist/resources/platform/add-on-attachment/index.d.ts +6 -0
  23. package/dist/resources/platform/add-on-attachment/index.js +6 -0
  24. package/dist/resources/platform/add-on-attachment/resolve.d.ts +80 -0
  25. package/dist/resources/platform/add-on-attachment/resolve.js +78 -0
  26. package/package.json +5 -1
@@ -1,6 +1,6 @@
1
1
  import type { BackupCreateResult, TransferInfoByAppResult } from '@heroku/types/data';
2
2
  import type { ResourceCtx } from '../../../core/extend-resource.js';
3
- import type { ResolvedAddOn } from '../../platform/add-on/index.js';
3
+ import type { ResolvedPgDatabase } from '../internal/resolve-pg-database.js';
4
4
  import type { WaitForTransferOptions } from '../wait-for-transfer.js';
5
5
  import { Poller } from '../../../utils/poller.js';
6
6
  export type CaptureAndWaitOptions = WaitForTransferOptions & {
@@ -8,7 +8,7 @@ export type CaptureAndWaitOptions = WaitForTransferOptions & {
8
8
  * Progress hooks fired once before capturing the backup `poller.onStart(addon)`
9
9
  * and once after the backup is created `poller.onStop(addon)`.
10
10
  */
11
- capturePoller?: Poller<ResolvedAddOn>;
11
+ capturePoller?: Poller<ResolvedPgDatabase>;
12
12
  /**
13
13
  * Progress hooks fired once before waiting for the transfer to
14
14
  * complete `poller.onStart(backup)` and once after the transfer
@@ -0,0 +1,10 @@
1
+ import type { AddOn } from '@heroku/types/3.sdk';
2
+ import { ResourceCtx } from '../../../core/extend-resource.js';
3
+ /**
4
+ * Returns an arbitrary legacy database add-on based on the provided app name.
5
+ */
6
+ export declare function getArbitraryLegacyDB(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string): Promise<AddOn>;
7
+ export declare const getAddonService: () => string;
8
+ export declare const isPostgresAddon: (addon: AddOn) => boolean;
9
+ export declare const isAdvancedDatabase: (addon: AddOn) => boolean;
10
+ export declare const isLegacyDatabase: (addon: AddOn) => boolean;
@@ -0,0 +1,17 @@
1
+ import createDebug from 'debug';
2
+ const debug = createDebug('postgres-utils');
3
+ /**
4
+ * Returns an arbitrary legacy database add-on based on the provided app name.
5
+ */
6
+ export async function getArbitraryLegacyDB(ctx, appIdentity) {
7
+ debug(`fetching arbitrary legacy database on ${appIdentity}`);
8
+ const addons = await ctx.platform.addOn.listByApp(appIdentity);
9
+ const addon = addons.find(a => a.app.name === appIdentity && isLegacyDatabase(a));
10
+ if (!addon)
11
+ throw new Error(`No Heroku Postgres legacy database on ${appIdentity}`);
12
+ return addon;
13
+ }
14
+ export const getAddonService = () => process.env.HEROKU_POSTGRESQL_ADDON_NAME || process.env.HEROKU_DATA_SERVICE || 'heroku-postgresql';
15
+ export const isPostgresAddon = (addon) => addon.plan?.name?.split(':', 2)[0] === getAddonService();
16
+ export const isAdvancedDatabase = (addon) => isPostgresAddon(addon) && /^(advanced|performance)/.test(addon.plan?.name?.split(':', 2)[1] ?? '');
17
+ export const isLegacyDatabase = (addon) => isPostgresAddon(addon) && !isAdvancedDatabase(addon);
@@ -1,23 +1,47 @@
1
1
  import type { ResourceCtx } from '../../../core/extend-resource.js';
2
- import type { ResolveAddonOptions, ResolvedAddOn } from '../../platform/add-on/index.js';
3
- export type ResolvePgDatabaseOptions = ResolveAddonOptions & {
2
+ import type { ResolveAddonAttachmentOptions, ResolvedAddOnAttachment } from '../../platform/add-on-attachment/resolve.js';
3
+ export type ResolvedPgDatabase = ResolvedAddOnAttachment['addon'];
4
+ export type ResolvedPgDatabaseWithAttachment = {
5
+ addon: ResolvedPgDatabase;
6
+ attachment: ResolvedAddOnAttachment;
7
+ };
8
+ export type ResolvePgDatabaseOptions = ResolveAddonAttachmentOptions & {
9
+ appIdentity?: string;
10
+ includeAttachment?: boolean;
4
11
  input?: string;
5
12
  };
6
13
  /**
7
- * Resolve a Heroku Postgres database add-on from any of the input
8
- * shapes the platform recognizes:
14
+ * Resolve a Heroku Postgres database add-on from an attachment identity.
9
15
  *
16
+ * `input` selects the attachment as follows:
10
17
  * - omitted → the `DATABASE_URL` attachment on `appIdentity`.
11
- * - `DATABASE_URL`, `HEROKU_POSTGRESQL_GREEN`, etc. → that attachment
12
- * on `appIdentity`. Detected by SHOUTY_SNAKE_CASE shape.
13
- * - `parent-app::branch-name` → branch reference. The portion before
14
- * `::` is the parent app; the portion after is the branch add-on
15
- * name.
16
- * - any other string → a bare add-on identity (UUID, globally-unique
17
- * name) routed through `resolveAddon`.
18
+ * - `parent-app::attachment-or-config-var` (e.g. `my-app::DATABASE_URL`)
19
+ * → an attachment or config var on `parent-app`.
20
+ * - `parent-app::branch-name` (e.g. `my-app::my-branch`) → branch add-on
21
+ * name on `parent-app`.
22
+ * - Both `::` forms are handled the same way here: we split on the first
23
+ * `::` ourselves and send `app`/`addon` as separate fields, which
24
+ * works for either shape.
25
+ * - any other string (attachment name, config var, UUID, or
26
+ * globally-unique add-on name) → routed through `resolveAddonAttachment`.
27
+ *
28
+ * This function only uses the attachment resolver. To resolve a database
29
+ * through the add-on resolve endpoint, call `resolveAddon` directly.
30
+ *
31
+ * The returned add-on is the identity nested on the attachment (`id`,
32
+ * `name`, `app`, and the plan stub), not a full `ResolvedAddOn`. To get
33
+ * that shape, call `resolveAddon` with the returned add-on's `id`.
34
+ *
35
+ * If the resolver finds multiple matches that all point at the same add-on
36
+ * (same `addon.id`/attachment `app.id`) and whose own `_URL` config var
37
+ * resolves to the same value on the app, they're treated as equivalent
38
+ * (e.g. the same database attached under two names) rather than ambiguous.
18
39
  *
19
- * Throws `AddonNotFoundError` (or `AddonAmbiguousError`) from the
20
- * underlying resolver. Throws if no input is given and no `appIdentity`
40
+ * Throws `AddonAttachmentNotFoundError` (or `AddonAttachmentAmbiguousError`)
41
+ * from the underlying resolver. Throws if no input is given and no `appIdentity`
21
42
  * is available to default the attachment lookup to.
22
43
  */
23
- export declare function resolvePgDatabase(ctx: Pick<ResourceCtx, 'platform'>, options?: ResolvePgDatabaseOptions): Promise<ResolvedAddOn>;
44
+ export declare function resolvePgDatabase(ctx: Pick<ResourceCtx, 'platform'>, options: ResolvePgDatabaseOptions & {
45
+ includeAttachment: true;
46
+ }): Promise<ResolvedPgDatabaseWithAttachment>;
47
+ export declare function resolvePgDatabase(ctx: Pick<ResourceCtx, 'platform'>, options?: ResolvePgDatabaseOptions): Promise<ResolvedPgDatabase>;
@@ -1,50 +1,64 @@
1
1
  import createDebug from 'debug';
2
- import { resolveAddon, resolveAddonByAttachment } from '../../platform/add-on/index.js';
2
+ import { AddonAttachmentAmbiguousError, resolveAddonAttachment } from '../../platform/add-on-attachment/resolve.js';
3
+ import { getAddonService } from './postgres-utils.js';
3
4
  const debug = createDebug('heroku:sdk:resources:database');
4
5
  const DEFAULT_PG_ATTACHMENT = 'DATABASE_URL';
5
- /**
6
- * Resolve a Heroku Postgres database add-on from any of the input
7
- * shapes the platform recognizes:
8
- *
9
- * - omitted → the `DATABASE_URL` attachment on `appIdentity`.
10
- * - `DATABASE_URL`, `HEROKU_POSTGRESQL_GREEN`, etc. → that attachment
11
- * on `appIdentity`. Detected by SHOUTY_SNAKE_CASE shape.
12
- * - `parent-app::branch-name` → branch reference. The portion before
13
- * `::` is the parent app; the portion after is the branch add-on
14
- * name.
15
- * - any other string → a bare add-on identity (UUID, globally-unique
16
- * name) routed through `resolveAddon`.
17
- *
18
- * Throws `AddonNotFoundError` (or `AddonAmbiguousError`) from the
19
- * underlying resolver. Throws if no input is given and no `appIdentity`
20
- * is available to default the attachment lookup to.
21
- */
6
+ const PG_ADDON_SERVICE = getAddonService();
22
7
  export async function resolvePgDatabase(ctx, options = {}) {
23
- const { appIdentity, input, ...rest } = options;
24
- if (!input) {
25
- if (!appIdentity) {
26
- throw new Error('resolvePgDatabase requires either input or appIdentity to default to DATABASE_URL.');
27
- }
28
- debug('resolve branch=default-attachment app=%s attachment=%s', appIdentity, DEFAULT_PG_ATTACHMENT);
29
- return resolveAddonByAttachment(ctx, appIdentity, DEFAULT_PG_ATTACHMENT, rest);
8
+ const { appIdentity, includeAttachment, input, ...rest } = options;
9
+ if (!input && !appIdentity) {
10
+ throw new Error('resolvePgDatabase requires either input or appIdentity to default to DATABASE_URL.');
30
11
  }
31
- if (input.includes('::')) {
32
- const { addon, app } = parseBranchReference(input, appIdentity);
33
- debug('resolve branch=branch-reference input=%s addon=%s app=%s', input, addon, app ?? '<global>');
34
- return resolveAddon(ctx, addon, { ...rest, appIdentity: app });
12
+ const databaseReference = input ?? DEFAULT_PG_ATTACHMENT;
13
+ const { addon, app } = databaseReference.includes('::')
14
+ ? parseBranchReference(databaseReference, appIdentity)
15
+ : { addon: databaseReference, app: appIdentity };
16
+ debug('resolve input=%s addon=%s app=%s', input ?? '<default>', addon, app ?? '<global>');
17
+ let attachment;
18
+ try {
19
+ attachment = await resolveAddonAttachment(ctx, app, addon, { ...rest, addonService: PG_ADDON_SERVICE });
35
20
  }
36
- if (appIdentity && isAttachmentName(input)) {
37
- debug('resolve branch=attachment-name app=%s attachment=%s', appIdentity, input);
38
- return resolveAddonByAttachment(ctx, appIdentity, input, rest);
21
+ catch (error) {
22
+ if (!(error instanceof AddonAttachmentAmbiguousError))
23
+ throw error;
24
+ const equivalent = collapseIfEquivalent(error.matches);
25
+ if (!equivalent)
26
+ throw error;
27
+ const config = await ctx.platform.configVar.infoForApp(equivalent[0].app.name);
28
+ if (!allMatchSameUrl(equivalent, config))
29
+ throw error;
30
+ attachment = equivalent[0];
39
31
  }
40
- debug('resolve branch=bare-identity input=%s app=%s', input, appIdentity ?? '<global>');
41
- return resolveAddon(ctx, input, { ...rest, appIdentity });
32
+ return includeAttachment ? { addon: attachment.addon, attachment } : attachment.addon;
42
33
  }
43
- function isAttachmentName(input) {
44
- return /^[A-Z][A-Z0-9_]*$/.test(input);
34
+ /**
35
+ * Narrows ambiguous matches to a same-add-on group worth comparing by
36
+ * config var value. Returns `undefined` (not worth a config-vars round
37
+ * trip) unless every match shares the same add-on and attachment app.
38
+ */
39
+ function collapseIfEquivalent(matches) {
40
+ const [first] = matches;
41
+ const sameAddon = matches.every(match => match.addon.id === first.addon.id && match.app.id === first.app.id);
42
+ return sameAddon ? matches : undefined;
43
+ }
44
+ /** The first config var name declared on an attachment that holds its connection URL. */
45
+ function urlConfigVarName(configVars) {
46
+ return configVars.find(name => name.endsWith('_URL'));
47
+ }
48
+ /** Whether every match's own URL config var resolves to the same value in `config`. */
49
+ function allMatchSameUrl(matches, config) {
50
+ const firstVarName = urlConfigVarName(matches[0].config_vars);
51
+ const firstValue = firstVarName && config[firstVarName];
52
+ if (!firstValue) {
53
+ return false;
54
+ }
55
+ return matches.every(match => {
56
+ const varName = urlConfigVarName(match.config_vars);
57
+ return varName !== undefined && config[varName] === firstValue;
58
+ });
45
59
  }
46
60
  function parseBranchReference(reference, fallbackApp) {
47
- const match = reference.match(/^(.+)::(.+)$/);
61
+ const match = reference.match(/^(.+?)::(.+)$/);
48
62
  if (match) {
49
63
  return { addon: match[2], app: match[1] };
50
64
  }
@@ -1,6 +1,6 @@
1
1
  import type { RestoreCreateResult, TransferInfoByAppResult } from '@heroku/types/data';
2
2
  import type { ResourceCtx } from '../../../core/extend-resource.js';
3
- import type { ResolvedAddOn } from '../../platform/add-on/index.js';
3
+ import type { ResolvedPgDatabase } from '../internal/resolve-pg-database.js';
4
4
  import type { WaitForTransferOptions } from '../wait-for-transfer.js';
5
5
  import { Poller } from '../../../utils/poller.js';
6
6
  export type RestoreAndWaitOptions = WaitForTransferOptions & {
@@ -13,7 +13,7 @@ export type RestoreAndWaitOptions = WaitForTransferOptions & {
13
13
  * `poller.onStart(addon)` and once after the restore is
14
14
  * created `poller.onStop(addon)`.
15
15
  */
16
- restorePoller?: Poller<ResolvedAddOn>;
16
+ restorePoller?: Poller<ResolvedPgDatabase>;
17
17
  /**
18
18
  * Progress hooks fired once before waiting for the restore to
19
19
  * complete `poller.onStart(restore)` and once after the restore
@@ -9,11 +9,11 @@ export type TransferSchedule = {
9
9
  export type TransferScheduleOptions = {
10
10
  signal?: AbortSignal;
11
11
  };
12
- export declare function list(ctx: Pick<ResourceCtx, 'data' | 'platform'>, appIdentity: string, addonIdentity?: string, options?: TransferScheduleOptions): Promise<TransferSchedule[]>;
12
+ export declare function list(ctx: Pick<ResourceCtx, 'data' | 'platform'>, appIdentity: string, options?: TransferScheduleOptions): Promise<TransferSchedule[]>;
13
13
  export declare function create(ctx: Pick<ResourceCtx, 'data' | 'platform'>, appIdentity: string, addonIdentity: string | undefined, body: TransferScheduleCreateOpts, options?: TransferScheduleOptions): Promise<TransferScheduleCreateResult>;
14
14
  export declare function del(ctx: Pick<ResourceCtx, 'data' | 'platform'>, appIdentity: string, addonIdentity: string | undefined, scheduleId: string, options?: TransferScheduleOptions): Promise<TransferScheduleDeleteResult>;
15
15
  export declare const transferScheduleExtensions: import("../../core/extend-resource.js").ResourceExtension<"data", "transferSchedule", {
16
16
  create: (appIdentity: string, addonIdentity: string | undefined, body: TransferScheduleCreateOpts, options?: TransferScheduleOptions) => Promise<TransferScheduleCreateResult>;
17
17
  delete: (appIdentity: string, addonIdentity: string | undefined, scheduleId: string, options?: TransferScheduleOptions) => Promise<TransferScheduleDeleteResult>;
18
- list: (appIdentity: string, addonIdentity?: string, options?: TransferScheduleOptions) => Promise<TransferSchedule[]>;
18
+ list: (appIdentity: string, options?: TransferScheduleOptions) => Promise<TransferSchedule[]>;
19
19
  }>;
@@ -1,14 +1,23 @@
1
1
  import { extendResource } from '../../core/extend-resource.js';
2
+ import { getArbitraryLegacyDB } from './internal/postgres-utils.js';
2
3
  import { resolvePgDatabase } from './internal/resolve-pg-database.js';
3
- export async function list(ctx, appIdentity, addonIdentity, options = {}) {
4
+ export async function list(ctx, appIdentity, options = {}) {
4
5
  options.signal?.throwIfAborted();
5
- const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
6
+ // Schedules are app-scoped, so any legacy DB ID unlocks the app's list.
7
+ const addon = await getArbitraryLegacyDB(ctx, appIdentity);
6
8
  return ctx.data.transferSchedule.list(addon.id);
7
9
  }
8
10
  export async function create(ctx, appIdentity, addonIdentity, body, options = {}) {
9
11
  options.signal?.throwIfAborted();
10
- const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
11
- return ctx.data.transferSchedule.create(addon.id, body);
12
+ const { addon, attachment } = await resolvePgDatabase(ctx, {
13
+ appIdentity, includeAttachment: true, input: addonIdentity, ...options,
14
+ });
15
+ let requestBody = body;
16
+ if (!requestBody.schedule_name) {
17
+ // eslint-disable-next-line camelcase
18
+ requestBody = { ...body, schedule_name: `${attachment.name}_URL` };
19
+ }
20
+ return ctx.data.transferSchedule.create(addon.id, requestBody);
12
21
  }
13
22
  export async function del(ctx, appIdentity, addonIdentity, scheduleId, options = {}) {
14
23
  options.signal?.throwIfAborted();
@@ -18,5 +27,5 @@ export async function del(ctx, appIdentity, addonIdentity, scheduleId, options =
18
27
  export const transferScheduleExtensions = extendResource('data', 'transferSchedule', ctx => ({
19
28
  create: (appIdentity, addonIdentity, body, options) => create(ctx, appIdentity, addonIdentity, body, options),
20
29
  delete: (appIdentity, addonIdentity, scheduleId, options) => del(ctx, appIdentity, addonIdentity, scheduleId, options),
21
- list: (appIdentity, addonIdentity, options) => list(ctx, appIdentity, addonIdentity, options),
30
+ list: (appIdentity, options) => list(ctx, appIdentity, options),
22
31
  }));
@@ -1,3 +1,4 @@
1
+ export { addOnAttachmentExtensions } from '../platform/add-on-attachment/index.js';
1
2
  export { addOnExtensions } from '../platform/add-on/index.js';
2
3
  export { appExtensions, type AppInfo, type DiffAppsOptions, type DiffRow, type PipelineCouplingDetail, } from '../platform/app/index.js';
3
4
  export { containerExtensions, type DockerReleasesBatchUpdateOpts, NotAContainerAppError, type ReleaseDockerImagesResult, type RemoveProcessTypesOpts, } from '../platform/container/index.js';
@@ -1,3 +1,4 @@
1
+ export { addOnAttachmentExtensions } from '../platform/add-on-attachment/index.js';
1
2
  export { addOnExtensions } from '../platform/add-on/index.js';
2
3
  export { appExtensions, } from '../platform/app/index.js';
3
4
  export { containerExtensions, NotAContainerAppError, } from '../platform/container/index.js';
@@ -8,11 +8,6 @@ import type { DescribedAddOn, ResolveAddonOptions } from './types.js';
8
8
  * - Returns a copy with `plan.price` set from `billed_price` (so the
9
9
  * value reflects any grandfathered/contract pricing).
10
10
  *
11
- * If `appIdentity` is provided and the platform returns 404 (resource
12
- * `add_on`), the resolve falls back to a global lookup. This handles
13
- * the case where the add-on belongs to a different app than the one
14
- * supplied.
15
- *
16
11
  * Requests the `version=3.sdk` accept variant with `addon_service,plan`
17
12
  * expansion so the resolved add-on includes the full `Plan` shape
18
13
  * (`price.unit`, etc.) needed to render pricing.
@@ -7,11 +7,6 @@ import { resolveAddonInternal } from './resolve.js';
7
7
  * - Returns a copy with `plan.price` set from `billed_price` (so the
8
8
  * value reflects any grandfathered/contract pricing).
9
9
  *
10
- * If `appIdentity` is provided and the platform returns 404 (resource
11
- * `add_on`), the resolve falls back to a global lookup. This handles
12
- * the case where the add-on belongs to a different app than the one
13
- * supplied.
14
- *
15
10
  * Requests the `version=3.sdk` accept variant with `addon_service,plan`
16
11
  * expansion so the resolved add-on includes the full `Plan` shape
17
12
  * (`price.unit`, etc.) needed to render pricing.
@@ -8,21 +8,19 @@ export { destroyAndWait } from './destroy-and-wait.js';
8
8
  export { AddonAmbiguousError, AddonConfirmationRequiredError, AddonNotFoundError, AddonProvisioningFailedError, } from './errors.js';
9
9
  export { listPlans, listPlansForAddon, type ListPlansForAddonOptions } from './list-plans.js';
10
10
  export { formatPlanPriceLabel, type FormatPlanPriceLabelOptions, type PlanPriceBreakdown, type PlanPriceKind, priceForPlan, } from './pricing.js';
11
- export { describeAttachment, resolveAddon, resolveAddonByAttachment } from './resolve.js';
12
- export type { AddOnOptions, CreateAndWaitOptions, DescribedAddOn, DestroyAndWaitOptions, ResolveAddonOptions, ResolvedAddOn, ResolvedAddOnAttachment, UpgradeAddOnOptions, WaitForProvisioningOptions, } from './types.js';
11
+ export { resolveAddon } from './resolve.js';
12
+ export type { AddOnOptions, CreateAndWaitOptions, DescribedAddOn, DestroyAndWaitOptions, ResolveAddonOptions, ResolvedAddOn, UpgradeAddOnOptions, WaitForProvisioningOptions, } from './types.js';
13
13
  export { upgrade } from './upgrade.js';
14
14
  export { waitForProvisioning } from './wait-for-provisioning.js';
15
15
  export declare const addOnExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "addOn", {
16
16
  createAndWait: (appIdentity: string, body: AddOnCreateOpts, options?: CreateAndWaitOptions) => Promise<AddOn>;
17
17
  describe: (addonIdentity: string, options?: ResolveAddonOptions) => Promise<import("./types.js").DescribedAddOn>;
18
- describeAttachment: (appIdentity: string, attachmentName: string, options?: AddOnOptions) => Promise<import("./types.js").ResolvedAddOnAttachment>;
19
18
  destroyAndWait: (appIdentity: string, addonIdentity: string, options?: DestroyAndWaitOptions) => Promise<AddOn>;
20
19
  formatPlanPriceLabel: typeof formatPlanPriceLabel;
21
20
  listPlans: (serviceIdentity: string, options?: AddOnOptions) => Promise<import("@heroku/types/3.sdk").Plan[]>;
22
21
  listPlansForAddon: (addonIdentity: string, options?: ListPlansForAddonOptions) => Promise<import("@heroku/types/3.sdk").Plan[]>;
23
22
  priceForPlan: typeof priceForPlan;
24
23
  resolve: (addonIdentity: string, options?: ResolveAddonOptions) => Promise<import("./types.js").ResolvedAddOn>;
25
- resolveByAttachment: (appIdentity: string, attachmentName: string, options?: AddOnOptions) => Promise<import("./types.js").ResolvedAddOn>;
26
24
  upgrade: (addonIdentity: string, plan: string, options?: UpgradeAddOnOptions) => Promise<AddOn>;
27
25
  waitForProvisioning: (addon: AddOn, options?: WaitForProvisioningOptions) => Promise<AddOn>;
28
26
  }>;
@@ -4,7 +4,7 @@ import { describeAddon } from './describe.js';
4
4
  import { destroyAndWait } from './destroy-and-wait.js';
5
5
  import { listPlans, listPlansForAddon } from './list-plans.js';
6
6
  import { formatPlanPriceLabel, priceForPlan } from './pricing.js';
7
- import { describeAttachment, resolveAddon, resolveAddonByAttachment } from './resolve.js';
7
+ import { resolveAddon } from './resolve.js';
8
8
  import { upgrade } from './upgrade.js';
9
9
  import { waitForProvisioning } from './wait-for-provisioning.js';
10
10
  export { createAndWait } from './create-and-wait.js';
@@ -13,20 +13,18 @@ export { destroyAndWait } from './destroy-and-wait.js';
13
13
  export { AddonAmbiguousError, AddonConfirmationRequiredError, AddonNotFoundError, AddonProvisioningFailedError, } from './errors.js';
14
14
  export { listPlans, listPlansForAddon } from './list-plans.js';
15
15
  export { formatPlanPriceLabel, priceForPlan, } from './pricing.js';
16
- export { describeAttachment, resolveAddon, resolveAddonByAttachment } from './resolve.js';
16
+ export { resolveAddon } from './resolve.js';
17
17
  export { upgrade } from './upgrade.js';
18
18
  export { waitForProvisioning } from './wait-for-provisioning.js';
19
19
  export const addOnExtensions = extendResource('platform', 'addOn', ctx => ({
20
20
  createAndWait: (appIdentity, body, options) => createAndWait(ctx, appIdentity, body, options),
21
21
  describe: (addonIdentity, options) => describeAddon(ctx, addonIdentity, options),
22
- describeAttachment: (appIdentity, attachmentName, options) => describeAttachment(ctx, appIdentity, attachmentName, options),
23
22
  destroyAndWait: (appIdentity, addonIdentity, options) => destroyAndWait(ctx, appIdentity, addonIdentity, options),
24
23
  formatPlanPriceLabel,
25
24
  listPlans: (serviceIdentity, options) => listPlans(ctx, serviceIdentity, options),
26
25
  listPlansForAddon: (addonIdentity, options) => listPlansForAddon(ctx, addonIdentity, options),
27
26
  priceForPlan,
28
27
  resolve: (addonIdentity, options) => resolveAddon(ctx, addonIdentity, options),
29
- resolveByAttachment: (appIdentity, attachmentName, options) => resolveAddonByAttachment(ctx, appIdentity, attachmentName, options),
30
28
  upgrade: (addonIdentity, plan, options) => upgrade(ctx, addonIdentity, plan, options),
31
29
  waitForProvisioning: (addon, options) => waitForProvisioning(ctx, addon, options),
32
30
  }));
@@ -23,10 +23,7 @@ export declare function listPlans(ctx: Pick<ResourceCtx, 'platform'>, serviceIde
23
23
  * the dance of fetching the add-on, plucking its service id/name, and
24
24
  * passing it through.
25
25
  *
26
- * `addonIdentity` accepts the same forms as `resolveAddon`:
27
- * - a UUID
28
- * - a globally-unique add-on name
29
- * - a namespaced credential reference (`name::SECONDARY`)
26
+ * `addonIdentity` accepts the same forms as `resolveAddon`.
30
27
  *
31
28
  * Pass `appIdentity` via `options` when the add-on identity is
32
29
  * scoped to an app (same semantics as `resolveAddon`).
@@ -19,10 +19,7 @@ export async function listPlans(ctx, serviceIdentity, options = {}) {
19
19
  * the dance of fetching the add-on, plucking its service id/name, and
20
20
  * passing it through.
21
21
  *
22
- * `addonIdentity` accepts the same forms as `resolveAddon`:
23
- * - a UUID
24
- * - a globally-unique add-on name
25
- * - a namespaced credential reference (`name::SECONDARY`)
22
+ * `addonIdentity` accepts the same forms as `resolveAddon`.
26
23
  *
27
24
  * Pass `appIdentity` via `options` when the add-on identity is
28
25
  * scoped to an app (same semantics as `resolveAddon`).
@@ -1,18 +1,20 @@
1
1
  import type { ResourceCtx } from '../../../core/extend-resource.js';
2
2
  import type { PlatformClient } from '../../../services/platform.js';
3
- import type { AddOnOptions, ResolveAddonOptions, ResolvedAddOn, ResolvedAddOnAttachment } from './types.js';
3
+ import type { ResolveAddonOptions, ResolvedAddOn } from './types.js';
4
4
  /**
5
5
  * Resolve a Platform add-on by identity.
6
6
  *
7
7
  * The add-on identity may be:
8
- * - a UUID (`d5e3f2a4-...`)
9
- * - a globally-unique name (`postgres-curved-12345`)
10
- * - a namespaced credential reference (`postgres-curved-12345::SECONDARY`)
8
+ * - an add-on UUID (`d5e3f2a4-...`)
9
+ * - a globally-unique add-on name (`postgres-curved-12345`)
10
+ * - an add-on service slug (`heroku-postgresql`)
11
+ * - an add-on plan slug (`heroku-postgresql:essential-0`)
11
12
  *
12
- * When `appIdentity` is provided, the resolve is scoped to that app first
13
- * and falls back to a global resolve if the platform returns 404 add_on.
14
- * Namespaced identities (containing `::`) skip the app-scoped lookup
15
- * because they are globally unique.
13
+ * It may also be an add-on attachment identity:
14
+ * - an attachment UUID (`2a4d5e3f-...`), name (`DATABASE`), or config
15
+ * var (`DATABASE_URL`). These identities require `appIdentity`.
16
+ * - an attachment name or config var prefixed with the app name
17
+ * (`my-app::DATABASE`, `my-app::DATABASE_URL`).
16
18
  *
17
19
  * `addonService` (e.g. `heroku-postgresql`) is filtered client-side after
18
20
  * the resolve. The platform's server-side `addon_service` filter excludes
@@ -21,38 +23,8 @@ import type { AddOnOptions, ResolveAddonOptions, ResolvedAddOn, ResolvedAddOnAtt
21
23
  * Errors:
22
24
  * - throws `AddonNotFoundError` if no match is found
23
25
  * - throws `AddonAmbiguousError` if multiple matches remain after filtering
24
- *
25
- * For attachment-based resolution (e.g. `DATABASE_URL` on a particular
26
- * app), use `resolveAddonByAttachment` instead.
27
26
  */
28
27
  export declare function resolveAddon(ctx: Pick<ResourceCtx, 'platform'>, addonIdentity: string, options?: ResolveAddonOptions): Promise<ResolvedAddOn>;
29
- /**
30
- * Describe an add-on *attachment* via its name on a given app.
31
- *
32
- * Use this when you have an attachment name (e.g. `DATABASE_URL`,
33
- * `HEROKU_POSTGRESQL_GREEN`) on a known app and need the attachment
34
- * itself — including its context-scoped `web_url` (the dashboard URL for
35
- * opening the add-on *from this app*, which differs from the add-on's own
36
- * billing-app `web_url`). Calls `addOnAttachment.resolution` and returns
37
- * the matched attachment with its resolved add-on.
38
- *
39
- * `web_url` is `string | null` by design: it is `null` for add-ons that
40
- * expose no web dashboard. A `null` here is a valid result, not a
41
- * not-found — callers who need a URL must handle it (e.g. fall back to
42
- * the add-on's own `web_url` via `resolveAddon`).
43
- *
44
- * To resolve just the add-on the attachment points to, use
45
- * `resolveAddonByAttachment`. For resolving by add-on identity, use
46
- * `resolveAddon`.
47
- */
48
- export declare function describeAttachment(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, attachmentName: string, options?: AddOnOptions): Promise<ResolvedAddOnAttachment>;
49
- /**
50
- * Resolve the add-on that an attachment points to, by attachment name on a
51
- * given app. A thin wrapper over `describeAttachment` that returns only the
52
- * resolved add-on (not the attachment). Use `describeAttachment` when you
53
- * need the attachment's context-scoped `web_url`.
54
- */
55
- export declare function resolveAddonByAttachment(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, attachmentName: string, options?: AddOnOptions): Promise<ResolvedAddOn>;
56
28
  export declare function resolveAddonInternal(platform: PlatformClient, addonIdentity: string, options?: {
57
29
  addonService?: string;
58
30
  appIdentity?: string;
@@ -1,18 +1,19 @@
1
- import { NotFoundError } from '@heroku/heroku-fetch';
2
1
  import { debug } from './debug.js';
3
2
  import { AddonAmbiguousError, AddonNotFoundError } from './errors.js';
4
3
  /**
5
4
  * Resolve a Platform add-on by identity.
6
5
  *
7
6
  * The add-on identity may be:
8
- * - a UUID (`d5e3f2a4-...`)
9
- * - a globally-unique name (`postgres-curved-12345`)
10
- * - a namespaced credential reference (`postgres-curved-12345::SECONDARY`)
7
+ * - an add-on UUID (`d5e3f2a4-...`)
8
+ * - a globally-unique add-on name (`postgres-curved-12345`)
9
+ * - an add-on service slug (`heroku-postgresql`)
10
+ * - an add-on plan slug (`heroku-postgresql:essential-0`)
11
11
  *
12
- * When `appIdentity` is provided, the resolve is scoped to that app first
13
- * and falls back to a global resolve if the platform returns 404 add_on.
14
- * Namespaced identities (containing `::`) skip the app-scoped lookup
15
- * because they are globally unique.
12
+ * It may also be an add-on attachment identity:
13
+ * - an attachment UUID (`2a4d5e3f-...`), name (`DATABASE`), or config
14
+ * var (`DATABASE_URL`). These identities require `appIdentity`.
15
+ * - an attachment name or config var prefixed with the app name
16
+ * (`my-app::DATABASE`, `my-app::DATABASE_URL`).
16
17
  *
17
18
  * `addonService` (e.g. `heroku-postgresql`) is filtered client-side after
18
19
  * the resolve. The platform's server-side `addon_service` filter excludes
@@ -21,92 +22,25 @@ import { AddonAmbiguousError, AddonNotFoundError } from './errors.js';
21
22
  * Errors:
22
23
  * - throws `AddonNotFoundError` if no match is found
23
24
  * - throws `AddonAmbiguousError` if multiple matches remain after filtering
24
- *
25
- * For attachment-based resolution (e.g. `DATABASE_URL` on a particular
26
- * app), use `resolveAddonByAttachment` instead.
27
25
  */
28
26
  export async function resolveAddon(ctx, addonIdentity, options = {}) {
29
27
  options.signal?.throwIfAborted();
30
28
  return resolveAddonInternal(ctx.platform, addonIdentity, options);
31
29
  }
32
- /**
33
- * Describe an add-on *attachment* via its name on a given app.
34
- *
35
- * Use this when you have an attachment name (e.g. `DATABASE_URL`,
36
- * `HEROKU_POSTGRESQL_GREEN`) on a known app and need the attachment
37
- * itself — including its context-scoped `web_url` (the dashboard URL for
38
- * opening the add-on *from this app*, which differs from the add-on's own
39
- * billing-app `web_url`). Calls `addOnAttachment.resolution` and returns
40
- * the matched attachment with its resolved add-on.
41
- *
42
- * `web_url` is `string | null` by design: it is `null` for add-ons that
43
- * expose no web dashboard. A `null` here is a valid result, not a
44
- * not-found — callers who need a URL must handle it (e.g. fall back to
45
- * the add-on's own `web_url` via `resolveAddon`).
46
- *
47
- * To resolve just the add-on the attachment points to, use
48
- * `resolveAddonByAttachment`. For resolving by add-on identity, use
49
- * `resolveAddon`.
50
- */
51
- export async function describeAttachment(ctx, appIdentity, attachmentName, options = {}) {
52
- options.signal?.throwIfAborted();
53
- debug('describeAttachment app=%s attachment=%s', appIdentity, attachmentName);
54
- // Scope the in-flight resolution call to the caller's signal so an abort
55
- // mid-request is honored, not just before it starts.
56
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
57
- const matches = await platform.addOnAttachment.resolution({
58
- // eslint-disable-next-line camelcase
59
- addon_attachment: attachmentName,
60
- app: appIdentity,
61
- });
62
- const attachment = matches[0];
63
- if (!attachment?.addon?.id || !attachment.addon.app?.id) {
64
- debug('describeAttachment matches=%d (no usable add-on returned)', matches.length);
65
- throw new AddonNotFoundError();
66
- }
67
- debug('describeAttachment resolved addon=%s app=%s', attachment.addon.id, attachment.addon.app.id);
68
- return attachment;
69
- }
70
- /**
71
- * Resolve the add-on that an attachment points to, by attachment name on a
72
- * given app. A thin wrapper over `describeAttachment` that returns only the
73
- * resolved add-on (not the attachment). Use `describeAttachment` when you
74
- * need the attachment's context-scoped `web_url`.
75
- */
76
- export async function resolveAddonByAttachment(ctx, appIdentity, attachmentName, options = {}) {
77
- return (await describeAttachment(ctx, appIdentity, attachmentName, options)).addon;
78
- }
79
30
  export async function resolveAddonInternal(platform, addonIdentity, options = {}) {
80
31
  const { addonService, appIdentity } = options;
81
- const resolveBy = async (app) => {
82
- const body = app ? { addon: addonIdentity, app } : { addon: addonIdentity };
83
- debug('resolve addon=%s app=%s service=%s', addonIdentity, app ?? '<global>', addonService ?? '<any>');
84
- const matches = await platform.addOn.resolution(body);
85
- const filtered = addonService
86
- ? matches.filter(addon => addon.addon_service?.name === addonService)
87
- : matches;
88
- debug('resolve matches=%d filtered=%d (service=%s)', matches.length, filtered.length, addonService ?? '<any>');
89
- const resolvedAddon = singularize(filtered);
90
- debug('resolve resolved addon=%s app=%s', resolvedAddon.id, resolvedAddon.app.id);
91
- return resolvedAddon;
92
- };
93
- if (!appIdentity || addonIdentity.includes('::')) {
94
- debug('resolve scope=global reason=%s', appIdentity ? 'namespaced-identity' : 'no-app');
95
- return resolveBy();
96
- }
97
- try {
98
- return await resolveBy(appIdentity);
99
- }
100
- catch (error) {
101
- if (isAddOnNotFound(error)) {
102
- debug('resolve app-scope 404 add_on, falling back to global addon=%s', addonIdentity);
103
- return resolveBy();
104
- }
105
- throw error;
106
- }
107
- }
108
- function isAddOnNotFound(error) {
109
- return error instanceof NotFoundError && error.resource === 'add_on';
32
+ // If the add-on identity is app-scoped (contains `::`), we do not pass `appIdentity`
33
+ const app = appIdentity && !addonIdentity.includes('::') ? appIdentity : undefined;
34
+ const body = app ? { addon: addonIdentity, app } : { addon: addonIdentity };
35
+ debug('resolve addon=%s app=%s service=%s', addonIdentity, app ?? '<global>', addonService ?? '<any>');
36
+ const matches = await platform.addOn.resolution(body);
37
+ const filtered = addonService
38
+ ? matches.filter(addon => addon.addon_service?.name === addonService)
39
+ : matches;
40
+ debug('resolve matches=%d filtered=%d (service=%s)', matches.length, filtered.length, addonService ?? '<any>');
41
+ const resolvedAddon = singularize(filtered);
42
+ debug('resolve resolved addon=%s app=%s', resolvedAddon.id, resolvedAddon.app.id);
43
+ return resolvedAddon;
110
44
  }
111
45
  function singularize(matches) {
112
46
  if (matches.length === 0) {
@@ -7,10 +7,14 @@ export type ResolveAddonOptions = AddOnOptions & {
7
7
  appIdentity?: string;
8
8
  };
9
9
  /**
10
- * An add-on whose `app.id` and `id` are guaranteed non-null. This is what
11
- * the Platform's resolver returns for a successful match, but the schema
12
- * types both as optional. `singularize` enforces it at runtime so callers
13
- * can rely on the narrower type.
10
+ * An add-on that has passed through a resolver's `singularize` check:
11
+ * `app.id` and `id` are guaranteed non-null at runtime. At the currently
12
+ * installed `@heroku/types` schema, `AddOn` already types these as
13
+ * required, so this doesn't narrow anything today — it exists to give the
14
+ * SDK a stable name decoupled from the generated type (insurance against
15
+ * that schema loosening these back to optional) and to document, by name,
16
+ * that a value carrying this type has actually been through resolution
17
+ * rather than a plain `list()`/`info()` call.
14
18
  */
15
19
  export type ResolvedAddOn = AddOn & {
16
20
  app: AddOn['app'] & {
@@ -18,24 +22,6 @@ export type ResolvedAddOn = AddOn & {
18
22
  };
19
23
  id: string;
20
24
  };
21
- /**
22
- * An attachment whose add-on `id`/`app.id` are guaranteed non-null (as the
23
- * resolver enforces at runtime). `web_url` is scoped to the attached app's
24
- * context — the dashboard URL for opening this add-on *from this app*, which
25
- * differs from the add-on's own (billing-app) `web_url`.
26
- *
27
- * `web_url` remains `string | null` by design (inherited from
28
- * `AddOnAttachment`): it is `null` for add-ons that expose no web dashboard.
29
- * The resolver does not guarantee a URL — callers must handle the `null` case.
30
- */
31
- export type ResolvedAddOnAttachment = AddOnAttachment & {
32
- addon: NonNullable<AddOnAttachment['addon']> & {
33
- app: {
34
- id: string;
35
- };
36
- id: string;
37
- };
38
- };
39
25
  export type DescribedAddOn = ResolvedAddOn & {
40
26
  attachments: AddOnAttachment[];
41
27
  };
@@ -5,9 +5,8 @@ import type { UpgradeAddOnOptions } from './types.js';
5
5
  * Change the plan of an add-on.
6
6
  *
7
7
  * Resolves the add-on first so the caller can pass any identifier
8
- * (UUID, globally unique name, or namespaced `service::name`). When
9
- * `appIdentity` is provided, the resolve is scoped to that app and
10
- * falls back to a global resolve if the platform returns 404 add_on.
8
+ * `resolveAddon` accepts. When `appIdentity` is provided, the resolve
9
+ * is scoped to that app.
11
10
  *
12
11
  * If `plan` is unqualified (no `:`), it's prefixed with the resolved
13
12
  * add-on's `addon_service.name` — so callers can pass `hobby` rather
@@ -4,9 +4,8 @@ import { resolveAddonInternal } from './resolve.js';
4
4
  * Change the plan of an add-on.
5
5
  *
6
6
  * Resolves the add-on first so the caller can pass any identifier
7
- * (UUID, globally unique name, or namespaced `service::name`). When
8
- * `appIdentity` is provided, the resolve is scoped to that app and
9
- * falls back to a global resolve if the platform returns 404 add_on.
7
+ * `resolveAddon` accepts. When `appIdentity` is provided, the resolve
8
+ * is scoped to that app.
10
9
  *
11
10
  * If `plan` is unqualified (no `:`), it's prefixed with the resolved
12
11
  * add-on's `addon_service.name` — so callers can pass `hobby` rather
@@ -0,0 +1,6 @@
1
+ import type { ResolveAddonAttachmentOptions } from './resolve.js';
2
+ export { AddonAttachmentAmbiguousError, AddonAttachmentNotFoundError, resolveAddonAttachment } from './resolve.js';
3
+ export type { ResolveAddonAttachmentOptions, ResolvedAddOnAttachment } from './resolve.js';
4
+ export declare const addOnAttachmentExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "addOnAttachment", {
5
+ resolve: (appIdentity: string | undefined, attachmentName: string, options?: ResolveAddonAttachmentOptions) => Promise<import("./resolve.js").ResolvedAddOnAttachment>;
6
+ }>;
@@ -0,0 +1,6 @@
1
+ import { extendResource } from '../../../core/extend-resource.js';
2
+ import { resolveAddonAttachment } from './resolve.js';
3
+ export { AddonAttachmentAmbiguousError, AddonAttachmentNotFoundError, resolveAddonAttachment } from './resolve.js';
4
+ export const addOnAttachmentExtensions = extendResource('platform', 'addOnAttachment', ctx => ({
5
+ resolve: (appIdentity, attachmentName, options) => resolveAddonAttachment(ctx, appIdentity, attachmentName, options),
6
+ }));
@@ -0,0 +1,80 @@
1
+ import type { AddOnAttachment } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ export declare class AddonAttachmentNotFoundError extends Error {
4
+ readonly resource: string;
5
+ readonly id = "not_found";
6
+ readonly statusCode = 404;
7
+ constructor(resource?: string);
8
+ get body(): {
9
+ id: string;
10
+ message: string;
11
+ resource: string;
12
+ };
13
+ }
14
+ export declare class AddonAttachmentAmbiguousError extends Error {
15
+ readonly matches: AddOnAttachmentWithInclusions[];
16
+ readonly id = "multiple_matches";
17
+ readonly statusCode = 422;
18
+ constructor(matches: AddOnAttachmentWithInclusions[]);
19
+ get body(): {
20
+ id: string;
21
+ message: string;
22
+ };
23
+ }
24
+ /**
25
+ * `Accept-Inclusion: addon:plan,config_vars` requests the add-on's plan and
26
+ * the attachment's config var names, but @heroku/types doesn't model either
27
+ * inclusion. Extend locally rather than casting through `any`.
28
+ */
29
+ export type AddOnAttachmentWithInclusions = AddOnAttachment & {
30
+ addon: AddOnAttachment['addon'] & {
31
+ plan: {
32
+ id?: string;
33
+ name: string;
34
+ };
35
+ };
36
+ config_vars: string[];
37
+ };
38
+ /**
39
+ * An attachment that has passed through `singularize`: its add-on `id`/
40
+ * `app.id` are guaranteed non-null at runtime. At the currently installed
41
+ * `@heroku/types` schema, `AddOnAttachment` already types these as
42
+ * required, so this doesn't narrow anything today — it exists to give the
43
+ * SDK a stable name decoupled from the generated type (insurance against
44
+ * that schema loosening these back to optional) and to document, by name,
45
+ * that a value carrying this type has actually been through resolution.
46
+ */
47
+ export type ResolvedAddOnAttachment = AddOnAttachmentWithInclusions & {
48
+ addon: NonNullable<AddOnAttachmentWithInclusions['addon']> & {
49
+ app: {
50
+ id: string;
51
+ };
52
+ id: string;
53
+ };
54
+ };
55
+ export type ResolveAddonAttachmentOptions = {
56
+ /**
57
+ * Restrict matches to attachments whose add-on plan belongs to this
58
+ * add-on service (e.g. `heroku-postgresql`), filtered client-side.
59
+ */
60
+ addonService?: string;
61
+ signal?: AbortSignal;
62
+ };
63
+ /**
64
+ * Resolve a Platform add-on *attachment* by identity.
65
+ *
66
+ * The add-on attachment identity may be:
67
+ * - an attachment UUID (`d5e3f2a4-...`)
68
+ * - an attachment name (`DATABASE`)
69
+ * - a config var (`DATABASE_URL`)
70
+ * - an app-scoped attachment name or config var (`my-app::DATABASE`,
71
+ * `my-app::DATABASE_URL`)
72
+ * - a globally-unique add-on name (`postgres-curved-12345`)
73
+ *
74
+ * To resolve just the add-on the attachment points to, use `resolveAddon`.
75
+ *
76
+ * Always sends `Accept-Inclusion: addon:plan,config_vars` on the resolve
77
+ * request so matches come back with their add-on's plan (needed to filter
78
+ * by `addonService`) and their own config var names.
79
+ */
80
+ export declare function resolveAddonAttachment(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string | undefined, attachmentName: string, options?: ResolveAddonAttachmentOptions): Promise<ResolvedAddOnAttachment>;
@@ -0,0 +1,78 @@
1
+ import createDebug from 'debug';
2
+ const debug = createDebug('heroku:sdk:resources:add-on-attachment');
3
+ export class AddonAttachmentNotFoundError extends Error {
4
+ resource;
5
+ id = 'not_found';
6
+ statusCode = 404;
7
+ constructor(resource = 'addon attachment') {
8
+ super(`Couldn't find that ${resource}.`);
9
+ this.resource = resource;
10
+ this.name = 'AddonAttachmentNotFoundError';
11
+ }
12
+ get body() {
13
+ return { id: this.id, message: this.message, resource: this.resource };
14
+ }
15
+ }
16
+ export class AddonAttachmentAmbiguousError extends Error {
17
+ matches;
18
+ id = 'multiple_matches';
19
+ statusCode = 422;
20
+ constructor(matches) {
21
+ super(`Ambiguous identifier; multiple matching add-on attachments found: ${matches.map(m => m.name).join(', ')}.`);
22
+ this.matches = matches;
23
+ this.name = 'AddonAttachmentAmbiguousError';
24
+ }
25
+ get body() {
26
+ return { id: this.id, message: this.message };
27
+ }
28
+ }
29
+ /**
30
+ * Resolve a Platform add-on *attachment* by identity.
31
+ *
32
+ * The add-on attachment identity may be:
33
+ * - an attachment UUID (`d5e3f2a4-...`)
34
+ * - an attachment name (`DATABASE`)
35
+ * - a config var (`DATABASE_URL`)
36
+ * - an app-scoped attachment name or config var (`my-app::DATABASE`,
37
+ * `my-app::DATABASE_URL`)
38
+ * - a globally-unique add-on name (`postgres-curved-12345`)
39
+ *
40
+ * To resolve just the add-on the attachment points to, use `resolveAddon`.
41
+ *
42
+ * Always sends `Accept-Inclusion: addon:plan,config_vars` on the resolve
43
+ * request so matches come back with their add-on's plan (needed to filter
44
+ * by `addonService`) and their own config var names.
45
+ */
46
+ export async function resolveAddonAttachment(ctx, appIdentity, attachmentName, options = {}) {
47
+ const { addonService, signal } = options;
48
+ signal?.throwIfAborted();
49
+ debug('resolve app=%s attachment=%s service=%s', appIdentity ?? '<global>', attachmentName, addonService ?? '<any>');
50
+ const platform = ctx.platform
51
+ .withHeaders({ 'Accept-Inclusion': 'addon:plan,config_vars' })
52
+ .withOptions({ signal });
53
+ const matches = await platform.addOnAttachment.resolution({
54
+ // eslint-disable-next-line camelcase
55
+ addon_attachment: attachmentName,
56
+ app: appIdentity,
57
+ });
58
+ const filtered = addonService
59
+ ? matches.filter(match => match.addon?.plan?.name?.split(':', 2)[0] === addonService)
60
+ : matches;
61
+ debug('resolve matches=%d filtered=%d (service=%s)', matches.length, filtered.length, addonService ?? '<any>');
62
+ const resolvedAttachment = singularize(filtered);
63
+ debug('resolve resolved attachment=%s addon=%s app=%s', resolvedAttachment.id, resolvedAttachment.addon.id, resolvedAttachment.addon.app.id);
64
+ return resolvedAttachment;
65
+ }
66
+ function singularize(matches) {
67
+ if (matches.length === 0) {
68
+ throw new AddonAttachmentNotFoundError();
69
+ }
70
+ if (matches.length > 1) {
71
+ throw new AddonAttachmentAmbiguousError(matches);
72
+ }
73
+ const match = matches[0];
74
+ if (!match.addon?.id || !match.addon?.app?.id) {
75
+ throw new Error(`Resolved attachment is missing required add-on fields (addon.id=${match.addon.id}, addon.app.id=${match.addon.app.id})`);
76
+ }
77
+ return match;
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroku/sdk",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "Heroku SDK",
5
5
  "author": "Heroku",
6
6
  "type": "module",
@@ -50,6 +50,10 @@
50
50
  "types": "./dist/resources/platform/add-on/index.d.ts",
51
51
  "default": "./dist/resources/platform/add-on/index.js"
52
52
  },
53
+ "./resources/platform/add-on-attachment": {
54
+ "types": "./dist/resources/platform/add-on-attachment/index.d.ts",
55
+ "default": "./dist/resources/platform/add-on-attachment/index.js"
56
+ },
53
57
  "./resources/platform/app": {
54
58
  "types": "./dist/resources/platform/app/index.d.ts",
55
59
  "default": "./dist/resources/platform/app/index.js"