@heroku/sdk 0.10.0 → 0.11.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.
package/README.md CHANGED
@@ -16,7 +16,7 @@ The simplest entry point is the `HerokuSDK` class. It exposes both the Platform
16
16
  import { HerokuSDK } from '@heroku/sdk'
17
17
  import { appExtensions } from '@heroku/sdk/extensions/platform'
18
18
 
19
- // Reads token from HEROKU_API_KEY or ~/.netrc
19
+ // Zero-arg reads the token from HEROKU_API_KEY (see Authentication below)
20
20
  const sdk = new HerokuSDK({ extensions: [appExtensions] })
21
21
 
22
22
  // Upstream route (auto-generated from the API spec)
@@ -49,6 +49,40 @@ You can also pass options directly:
49
49
  const sdk = new HerokuSDK({ clientOptions: { token: 'your-api-token' } })
50
50
  ```
51
51
 
52
+ ### Authentication
53
+
54
+ The SDK core is transport-agnostic: it reads no local files or keychains itself,
55
+ so it also runs in the browser. Token resolution is handled by
56
+ `@heroku/heroku-fetch`, which has a single default and otherwise defers to
57
+ whatever you pass in:
58
+
59
+ 1. **`HEROKU_API_KEY` (the default)** — with no explicit token, heroku-fetch's
60
+ default provider reads `HEROKU_API_KEY` from the environment, so a zero-arg
61
+ `new HerokuSDK()` is authenticated whenever that variable is set.
62
+ 2. **An explicit `token` (opt in to anything else)** — pass `clientOptions.token`
63
+ (or a per-service `clientOptionsByService`) and the SDK uses it instead of the
64
+ `HEROKU_API_KEY` default. This is how you authenticate from any other
65
+ credential source — for example a token stored by `heroku login`, which the
66
+ Heroku CLI resolves and injects itself (see the note below).
67
+
68
+ > **Note:** To authenticate as a logged-in Heroku CLI user, resolve the stored
69
+ > token in your (Node) application with
70
+ > [`@heroku/heroku-credential-manager`](https://github.com/heroku/heroku-credential-manager)
71
+ > and pass it in:
72
+ >
73
+ > ```ts
74
+ > import { getAuth } from '@heroku/heroku-credential-manager'
75
+ > import { HerokuSDK } from '@heroku/sdk'
76
+ >
77
+ > // account: your Heroku login email; host: the API host (usually api.heroku.com)
78
+ > const { token } = await getAuth(account, 'api.heroku.com')
79
+ > const sdk = new HerokuSDK({ clientOptions: { token } })
80
+ > ```
81
+ >
82
+ > This keeps keychain/filesystem access in the Node consumer (CLI, MCP server,
83
+ > VS Code extension) rather than in the SDK core, so the SDK stays usable in the
84
+ > browser.
85
+
52
86
  Common client options apply to every service. In particular, a common `baseUrl`
53
87
  overrides every service's default host, including Metrics and Repositories. Use
54
88
  shallow per-service overrides when only specific services should use a host or
@@ -1,11 +1,15 @@
1
1
  import type { AddOn } from '@heroku/types/3.sdk';
2
- import { ResourceCtx } from '../../../core/extend-resource.js';
3
- import { AddOnAttachmentWithInclusions } from '../../platform/add-on-attachment/resolve.js';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ import type { AddOnAttachmentWithInclusions } from '../../platform/add-on-attachment/resolve.js';
4
4
  /**
5
- * Returns an arbitrary legacy database add-on based on the provided app name.
5
+ * Returns the first legacy database add-on on the app.
6
6
  */
7
- export declare function getArbitraryLegacyDB(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string): Promise<AddOn>;
8
- export declare const getAddonService: () => string;
9
- export declare const isPostgresAddon: (addon: AddOn | AddOnAttachmentWithInclusions["addon"]) => boolean;
7
+ export declare function findLegacyDatabase(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string): Promise<AddOn>;
8
+ export declare const getPostgresServiceName: () => string;
9
+ /**
10
+ * `AddOnAttachmentWithInclusions` is an internal type. The public `isPostgresAddon` takes `AddOn` only.
11
+ */
12
+ export declare const isPostgresAddonInternal: (addon: AddOn | AddOnAttachmentWithInclusions["addon"]) => boolean;
13
+ export declare const isPostgresAddon: (addon: AddOn) => boolean;
10
14
  export declare const isAdvancedDatabase: (addon: AddOn) => boolean;
11
15
  export declare const isLegacyDatabase: (addon: AddOn) => boolean;
@@ -1,9 +1,9 @@
1
1
  import createDebug from 'debug';
2
- const debug = createDebug('postgres-utils');
2
+ const debug = createDebug('heroku:sdk:resources:postgres-database');
3
3
  /**
4
- * Returns an arbitrary legacy database add-on based on the provided app name.
4
+ * Returns the first legacy database add-on on the app.
5
5
  */
6
- export async function getArbitraryLegacyDB(ctx, appIdentity) {
6
+ export async function findLegacyDatabase(ctx, appIdentity) {
7
7
  debug(`fetching arbitrary legacy database on ${appIdentity}`);
8
8
  const addons = await ctx.platform.addOn.listByApp(appIdentity);
9
9
  const addon = addons.find(a => a.app.name === appIdentity && isLegacyDatabase(a));
@@ -11,7 +11,11 @@ export async function getArbitraryLegacyDB(ctx, appIdentity) {
11
11
  throw new Error(`No Heroku Postgres legacy database on ${appIdentity}`);
12
12
  return addon;
13
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);
14
+ export const getPostgresServiceName = () => process.env.HEROKU_POSTGRESQL_ADDON_NAME || process.env.HEROKU_DATA_SERVICE || 'heroku-postgresql';
15
+ /**
16
+ * `AddOnAttachmentWithInclusions` is an internal type. The public `isPostgresAddon` takes `AddOn` only.
17
+ */
18
+ export const isPostgresAddonInternal = (addon) => addon.plan?.name?.split(':', 2)[0] === getPostgresServiceName();
19
+ export const isPostgresAddon = (addon) => isPostgresAddonInternal(addon);
20
+ export const isAdvancedDatabase = (addon) => isPostgresAddonInternal(addon) && /^(advanced|performance)/.test(addon.plan?.name?.split(':', 2)[1] ?? '');
21
+ export const isLegacyDatabase = (addon) => isPostgresAddonInternal(addon) && !isAdvancedDatabase(addon);
@@ -10,57 +10,60 @@ export type ResolvePgDatabaseOptions = ResolveAddonAttachmentOptions & {
10
10
  input?: string;
11
11
  };
12
12
  /**
13
- * Thrown by `resolvePgDatabase` when the attachment resolution endpoint
14
- * does not find a match, the matched attachment is not the configured
15
- * Postgres service, or the app has no Postgres databases at all.
13
+ * Thrown by `resolvePgDatabase` when a database cannot be resolved: the app
14
+ * has no Postgres databases, the attachment is not found, or the matched
15
+ * attachment is not the configured Postgres service.
16
16
  */
17
17
  export declare class PgDatabaseNotFoundError extends Error {
18
- constructor(message: string, options?: {
19
- cause?: unknown;
20
- });
18
+ readonly name = "PgDatabaseNotFoundError";
21
19
  }
22
20
  /**
23
21
  * Resolve a Heroku Postgres database add-on from an attachment identity.
24
22
  *
25
23
  * `input` selects the attachment as follows:
26
- * - omitted → the `DATABASE_URL` attachment on `appIdentity`.
24
+ * - omitted or `''` → the `DATABASE_URL` attachment on `appIdentity`.
27
25
  * - `parent-app::attachment-or-config-var` (e.g. `my-app::DATABASE_URL`)
28
26
  * → an attachment or config var on `parent-app`.
29
27
  * - `parent-app::branch-name` (e.g. `my-app::my-branch`) → branch add-on
30
28
  * name on `parent-app`.
31
- * - Both `::` forms are handled the same way here: we split on the first
32
- * `::` ourselves and send `app`/`addon` as separate fields, which
33
- * works for either shape.
34
29
  * - any other string (attachment name, config var, UUID, or
35
30
  * globally-unique add-on name) → routed through `resolveAddonAttachment`,
36
31
  * scoped to `appIdentity`.
37
32
  *
38
- * This function only uses the attachment resolver. To resolve a database
39
- * through the add-on resolve endpoint, call `resolveAddon` directly.
33
+ * This function intentionally relies on the attachment resolve endpoint. To
34
+ * resolve a database through the add-on resolve endpoint, call `resolveAddon`
35
+ * directly.
40
36
  *
41
37
  * The returned add-on is the identity nested on the attachment (`id`,
42
38
  * `name`, `app`, and the plan stub), not a full `ResolvedAddOn`. To get
43
39
  * that shape, call `resolveAddon` with the returned add-on's `id`.
44
40
  *
45
- * If the resolver finds multiple matches that all point at the same add-on
41
+ * A single resolver match is returned directly.
42
+ *
43
+ * If the resolver finds multiple matches that all point to the same add-on
46
44
  * (same `addon.id`/attachment `app.id`) and whose own `_URL` config var
47
45
  * resolves to the same value on the app, they're treated as equivalent
48
46
  * (e.g. the same database attached under two names) rather than ambiguous.
49
47
  *
48
+ * If the resolver finds no match at all, falls back to treating `input` as
49
+ * a literal config var name (adding a `_URL` suffix if missing) and looking
50
+ * for a Postgres attachment on the app whose own `_URL` config var resolves
51
+ * to the same value. This covers an app config var that is not one of the
52
+ * attachment's own config vars, but whose value is the same database URL.
53
+ *
50
54
  * Throws a `PgDatabaseNotFoundError` when the resolve endpoint 404s, the matched
51
55
  * attachment is not the configured Postgres service, or the app has no databases.
52
56
  * Throws an `AddonAttachmentAmbiguousError` when the remaining matches are not
53
57
  * the same database. Throws a plain `Error` if `appIdentity` is empty and `input`
54
58
  * has no `::`-prefixed app to fall back on.
55
59
  *
56
- * `resolvePgDatabase` should not throw a bare `NotFoundError`.
57
- * Extensions that use it typically couple it with a second data call
58
- * (e.g. `data.database.info(addon.id)`) that can also 404. Callers often
59
- * catch `NotFoundError` to handle that second call's failure specifically.
60
- * However, if a resolution failure surfaced as a `NotFoundError` too, that
61
- * catch would swallow it and mislabel it as the second call's failure instead.
62
- * Resolution failures are reported as `PgDatabaseNotFoundError` so the two stay
63
- * separable by type.
60
+ * `resolvePgDatabase` should not throw a bare `NotFoundError`. Extensions that
61
+ * use it typically couple it with a second data call (e.g. `data.database.info(addon.id)`)
62
+ * that can also 404. Callers often catch `NotFoundError` to handle that second
63
+ * call's failure specifically. However, if a resolution failure surfaced as a
64
+ * `NotFoundError` too, that catch would swallow it and mislabel it as the second
65
+ * call's failure instead. Resolution failures are reported as `PgDatabaseNotFoundError`
66
+ * so the two stay separable by type.
64
67
  */
65
68
  export declare function resolvePgDatabase(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options: ResolvePgDatabaseOptions & {
66
69
  includeAttachment: true;
@@ -1,135 +1,201 @@
1
1
  import { NotFoundError } from '@heroku/heroku-fetch';
2
2
  import createDebug from 'debug';
3
3
  import { AddonAttachmentAmbiguousError, AddonAttachmentNotFoundError, resolveAddonAttachment } from '../../platform/add-on-attachment/resolve.js';
4
- import { getAddonService, isPostgresAddon } from './postgres-utils.js';
4
+ import { getPostgresServiceName, isPostgresAddonInternal } from './postgres-utils.js';
5
5
  const debug = createDebug('heroku:sdk:resources:database');
6
6
  const DEFAULT_PG_ATTACHMENT = 'DATABASE_URL';
7
7
  /**
8
- * Thrown by `resolvePgDatabase` when the attachment resolution endpoint
9
- * does not find a match, the matched attachment is not the configured
10
- * Postgres service, or the app has no Postgres databases at all.
8
+ * Thrown by `resolvePgDatabase` when a database cannot be resolved: the app
9
+ * has no Postgres databases, the attachment is not found, or the matched
10
+ * attachment is not the configured Postgres service.
11
11
  */
12
12
  export class PgDatabaseNotFoundError extends Error {
13
- constructor(message, options) {
14
- super(message, options);
15
- this.name = 'PgDatabaseNotFoundError';
16
- }
13
+ name = 'PgDatabaseNotFoundError';
17
14
  }
18
15
  export async function resolvePgDatabase(ctx, appIdentity, options = {}) {
19
- const { includeAttachment, input, ...rest } = options;
20
- const databaseReference = input ?? DEFAULT_PG_ATTACHMENT;
21
- const { addon, app } = databaseReference.includes('::')
22
- ? parseBranchReference(databaseReference, appIdentity)
23
- : { addon: databaseReference, app: appIdentity };
24
- if (!app) {
16
+ const { includeAttachment, input, signal, ...rest } = options;
17
+ const databaseReference = input || DEFAULT_PG_ATTACHMENT;
18
+ const { app: parentApp, attachmentId } = parseDatabaseReference(databaseReference, appIdentity);
19
+ if (!parentApp) {
25
20
  throw new Error('resolvePgDatabase requires a non-empty app identity.');
26
21
  }
27
- debug('resolve input=%s addon=%s app=%s', input ?? '<default>', addon, app);
22
+ debug('resolve input=%s attachment=%s app=%s', input ?? '<default>', attachmentId, parentApp);
28
23
  let attachment;
29
24
  try {
30
- attachment = await resolveAddonAttachment(ctx, app, addon, { ...rest, addonService: getAddonService() });
25
+ attachment = await resolveAddonAttachment(ctx, parentApp, attachmentId, { ...rest, addonService: getPostgresServiceName(), signal });
31
26
  }
32
27
  catch (error) {
33
- if (error instanceof NotFoundError || error instanceof AddonAttachmentNotFoundError) {
34
- throw await buildDatabaseNotFoundError(ctx, app, addon, error, rest.signal);
35
- }
36
- if (error instanceof AddonAttachmentAmbiguousError) {
37
- const equivalent = collapseIfEquivalent(error.matches);
38
- if (!equivalent)
39
- throw error;
40
- let config;
41
- try {
42
- config = await ctx.platform
43
- .withOptions({ signal: rest.signal })
44
- .configVar.infoForApp(equivalent[0].app.name);
45
- }
46
- catch (configVarError) {
47
- rest.signal?.throwIfAborted();
48
- debug('config var lookup failed while collapsing ambiguous matches: %O', configVarError);
49
- throw error;
50
- }
51
- if (!allMatchSameUrl(equivalent, config))
52
- throw error;
53
- attachment = equivalent[0];
28
+ attachment = await attachmentResolutionFallback(ctx, parentApp, attachmentId, error, signal);
29
+ }
30
+ return includeAttachment ? { addon: attachment.addon, attachment } : attachment.addon;
31
+ }
32
+ /**
33
+ * Fallback when `resolveAddonAttachment` throws. Returns an attachment when the failure
34
+ * can be recovered from:
35
+ * - `NotFoundError` or `AddonAttachmentNotFoundError` → list the app's Postgres attachments.
36
+ * An empty list throws `PgDatabaseNotFoundError`. Otherwise treat `attachmentId` as a config
37
+ * var (adding `_URL` if missing) and keep every attachment whose own `_URL` value equals it.
38
+ * Those matches already share one `_URL` value by construction, so only a same-add-on check
39
+ * is needed before returning the first one. No matches, or matches that span more than one
40
+ * add-on, throws `PgDatabaseNotFoundError`.
41
+ * - `AddonAttachmentAmbiguousError` → collapse `error.matches` to one attachment via
42
+ * `equivalentAttachment` when they're the same database (same add-on, same attachment app,
43
+ * and the same non-empty `_URL` value fetched from the app's config vars). Otherwise rethrow.
44
+ * - any other error → rethrow.
45
+ */
46
+ async function attachmentResolutionFallback(ctx, appIdentity, attachmentId, error, signal) {
47
+ if (isAttachmentNotFoundError(error)) {
48
+ const attachments = await listPostgresAttachments(ctx, appIdentity, attachmentId, error, signal);
49
+ if (attachments.length === 0) {
50
+ throw new PgDatabaseNotFoundError(`${appIdentity} has no ${getPostgresServiceName()} databases`, { cause: error });
54
51
  }
55
- else {
52
+ const resolutionError = buildDatabaseNotFoundError(appIdentity, attachmentId, attachments, error);
53
+ const matches = await resolveAttachmentViaConfigVarName(ctx, appIdentity, attachmentId, attachments, resolutionError, signal);
54
+ if (matches.length === 0 || !sameAddon(matches))
55
+ throw resolutionError;
56
+ return matches[0];
57
+ }
58
+ if (error instanceof AddonAttachmentAmbiguousError) {
59
+ const equivalent = await equivalentAttachment(ctx, error.matches, error, signal);
60
+ if (!equivalent)
56
61
  throw error;
57
- }
62
+ return equivalent;
58
63
  }
59
- return includeAttachment ? { addon: attachment.addon, attachment } : attachment.addon;
64
+ throw error;
60
65
  }
61
- async function buildDatabaseNotFoundError(ctx, appIdentity, attachmentId, error, signal) {
66
+ /**
67
+ * Used by `attachmentResolutionFallback` to collapse ambiguous matches to the first
68
+ * attachment when all matches target the same database: same add-on, same attachment
69
+ * app, and the same non-empty `_URL` value. Returns `undefined` when the matches are
70
+ * not equivalent or the first match has no `_URL` value. `matches` must be non-empty.
71
+ */
72
+ async function equivalentAttachment(ctx, matches, error, signal) {
73
+ const [first] = matches;
74
+ if (!sameAddon(matches))
75
+ return undefined;
76
+ const appConfig = await listAppConfigVars(ctx, first.app.name, error, signal);
77
+ const firstUrlValue = getUrlConfigVarValue(first, appConfig);
78
+ if (!firstUrlValue)
79
+ return undefined;
80
+ const sameDatabaseUrl = matches.every(match => getUrlConfigVarValue(match, appConfig) === firstUrlValue);
81
+ return sameDatabaseUrl ? first : undefined;
82
+ }
83
+ /**
84
+ * Used by `attachmentResolutionFallback` to list an app's config vars. An aborted
85
+ * signal throws the abort error. Any other listing failure throws `error` (the
86
+ * resolution error for this attempt).
87
+ */
88
+ async function listAppConfigVars(ctx, appIdentity, error, signal) {
89
+ try {
90
+ return await ctx.platform
91
+ .withOptions({ signal })
92
+ .configVar.infoForApp(appIdentity);
93
+ }
94
+ catch (configVarError) {
95
+ signal?.throwIfAborted();
96
+ debug('config var lookup failed while attempting to resolve via fallback: %O', configVarError);
97
+ throw error;
98
+ }
99
+ }
100
+ /**
101
+ * Used by `attachmentResolutionFallback` to list all attachments on an app that match the
102
+ * add-on service filter. Throws `PgDatabaseNotFoundError` if the app does not exist
103
+ * or the listing fails for any other reason.
104
+ */
105
+ async function listPostgresAttachments(ctx, appIdentity, attachmentId, error, signal) {
62
106
  let attachments;
63
107
  try {
64
- attachments = await listPostgresAttachments(ctx, appIdentity, signal);
108
+ attachments = await ctx.platform
109
+ .withHeaders({ 'Accept-Inclusion': 'addon:plan,config_vars' })
110
+ .withOptions({ signal })
111
+ .addOnAttachment.listByApp(appIdentity);
65
112
  }
66
113
  catch (listError) {
67
114
  signal?.throwIfAborted();
68
115
  if (listError instanceof NotFoundError) {
69
- // `NotFoundError` from the attachment resolution endpoint is ambiguous.
70
- // It could be because the app doesn't exist, or because the attachment
71
- // doesn't exist. The endpoint for listing all attachments only 404s when
72
- // the app doesn't exist, so we rethrow the error to cover that case.
116
+ // Listing attachments 404s only when the app does not exist, which would
117
+ // also be the reason for the attachment resolution failure. Therefore,
118
+ // we surface this error specifically.
73
119
  throw new PgDatabaseNotFoundError(`Couldn't find that app: ${appIdentity}`, { cause: listError });
74
120
  }
75
121
  debug('listing attachments failed while building not-found error: %O', listError);
76
122
  throw new PgDatabaseNotFoundError(`Could not resolve ${stripUrlSuffix(attachmentId)} on ${appIdentity}`, { cause: error });
77
123
  }
78
- if (attachments.length === 0) {
79
- return new PgDatabaseNotFoundError(`${appIdentity} has no ${getAddonService()} databases`, { cause: error });
80
- }
124
+ return attachments.filter(attachment => isPostgresAddonInternal(attachment.addon));
125
+ }
126
+ /**
127
+ * Used by `attachmentResolutionFallback` to resolve attachments via app config vars
128
+ * when no matches were found. Treats `attachmentId` as a config var name and
129
+ * uses its value to filter the attachments. Returns an empty array if the
130
+ * config var value is not found.
131
+ */
132
+ async function resolveAttachmentViaConfigVarName(ctx, appIdentity, attachmentId, attachments, error, signal) {
133
+ const appConfig = await listAppConfigVars(ctx, appIdentity, error, signal);
134
+ const inputConfigVarName = attachmentId.endsWith('_URL') ? attachmentId : `${attachmentId}_URL`;
135
+ const inputConfigVarValue = appConfig[inputConfigVarName];
136
+ if (!inputConfigVarValue)
137
+ return [];
138
+ return attachments.filter(attachment => getUrlConfigVarValue(attachment, appConfig) === inputConfigVarValue);
139
+ }
140
+ /**
141
+ * Builds a `PgDatabaseNotFoundError` when attachment resolution endpoint 404s (NotFoundError)
142
+ * or attachments did not match the add-on service filter (AddonAttachmentNotFoundError).
143
+ */
144
+ function buildDatabaseNotFoundError(appIdentity, attachmentId, attachments, error) {
81
145
  const validOptions = attachments
82
146
  .map(attachment => {
83
- const urlVarName = urlConfigVarName(attachment.config_vars);
147
+ const urlVarName = getUrlConfigVarName(attachment.config_vars);
84
148
  return urlVarName ? stripUrlSuffix(urlVarName) : attachment.name;
85
149
  })
86
150
  .join(', ');
87
151
  const databaseName = stripUrlSuffix(attachmentId);
88
152
  if (error instanceof AddonAttachmentNotFoundError) {
89
- return new PgDatabaseNotFoundError(`${databaseName} is not a ${getAddonService()} database on ${appIdentity}. Valid options are: ${validOptions}`, { cause: error });
153
+ return new PgDatabaseNotFoundError(`${databaseName} is not a ${getPostgresServiceName()} database on ${appIdentity}. Valid options are: ${validOptions}`, { cause: error });
90
154
  }
91
155
  return new PgDatabaseNotFoundError(`Unknown database: ${databaseName} on ${appIdentity}. Valid options are: ${validOptions}`, { cause: error });
92
156
  }
93
- async function listPostgresAttachments(ctx, appIdentity, signal) {
94
- const attachments = await ctx.platform
95
- .withHeaders({ 'Accept-Inclusion': 'addon:plan,config_vars' })
96
- .withOptions({ signal })
97
- .addOnAttachment.listByApp(appIdentity);
98
- return attachments.filter(attachment => isPostgresAddon(attachment.addon));
99
- }
100
- function stripUrlSuffix(name) {
101
- return name.endsWith('_URL') ? name.slice(0, -4) : name;
102
- }
103
157
  /**
104
- * Narrows ambiguous matches to a same-add-on group worth comparing by
105
- * config var value. Returns `undefined` (not worth a config-vars round
106
- * trip) unless every match shares the same add-on and attachment app.
158
+ * Returns the first config var name declared on an attachment that holds its connection
159
+ * URL or `undefined` if no such config var is found.
107
160
  */
108
- function collapseIfEquivalent(matches) {
109
- const [first] = matches;
110
- const sameAddon = matches.every(match => match.addon.id === first.addon.id && match.app.id === first.app.id);
111
- return sameAddon ? matches : undefined;
112
- }
113
- /** The first config var name declared on an attachment that holds its connection URL. */
114
- function urlConfigVarName(configVars) {
161
+ function getUrlConfigVarName(configVars) {
115
162
  return configVars.find(name => name.endsWith('_URL'));
116
163
  }
117
- /** Whether every match's own URL config var resolves to the same value in `config`. */
118
- function allMatchSameUrl(matches, config) {
119
- const firstVarName = urlConfigVarName(matches[0].config_vars);
120
- const firstValue = firstVarName && config[firstVarName];
121
- if (!firstValue) {
122
- return false;
123
- }
124
- return matches.every(match => {
125
- const varName = urlConfigVarName(match.config_vars);
126
- return varName !== undefined && config[varName] === firstValue;
127
- });
164
+ /**
165
+ * Looks up the value of `attachment`'s own `_URL` config var in `appConfig`, or
166
+ * `undefined` if the attachment has no `_URL`-suffixed config var.
167
+ */
168
+ function getUrlConfigVarValue(attachment, appConfig) {
169
+ const urlVarName = getUrlConfigVarName(attachment.config_vars);
170
+ return urlVarName ? appConfig[urlVarName] : undefined;
128
171
  }
129
- function parseBranchReference(reference, fallbackApp) {
172
+ /**
173
+ * Returns true if `error` is a NotFoundError (attachment resolution endpoint 404) or
174
+ * AddonAttachmentNotFoundError (attachments did not match the add-on service filter).
175
+ */
176
+ function isAttachmentNotFoundError(error) {
177
+ return error instanceof AddonAttachmentNotFoundError || error instanceof NotFoundError;
178
+ }
179
+ /**
180
+ * Splits an `app::database` reference into the app and the attachment id.
181
+ * A reference with no `::` uses `appIdentity` as the app and the whole
182
+ * reference as the attachment id.
183
+ */
184
+ function parseDatabaseReference(reference, appIdentity) {
130
185
  const match = reference.match(/^(.+?)::(.+)$/);
131
186
  if (match) {
132
- return { addon: match[2], app: match[1] };
187
+ return { app: match[1], attachmentId: match[2] };
133
188
  }
134
- return { addon: reference, app: fallbackApp };
189
+ return { app: appIdentity, attachmentId: reference };
190
+ }
191
+ /** Whether every match shares the same add-on and attachment app. */
192
+ function sameAddon(matches) {
193
+ const [first] = matches;
194
+ return matches.every(match => match.addon.id === first.addon.id && match.app.id === first.app.id);
195
+ }
196
+ /**
197
+ * Removes the `_URL` suffix from a config var name if it exists.
198
+ */
199
+ function stripUrlSuffix(name) {
200
+ return name.endsWith('_URL') ? name.slice(0, -4) : name;
135
201
  }
@@ -20,3 +20,4 @@ export declare function listCredentials(ctx: Pick<ResourceCtx, 'data' | 'platfor
20
20
  export declare const postgresDatabaseExtensions: import("../../core/extend-resource.js").ResourceExtension<"data", "postgresDatabase", {
21
21
  listCredentials: (appIdentity: string, addonIdentity?: string, options?: ListCredentialsOptions) => Promise<CredentialInfo[]>;
22
22
  }>;
23
+ export { findLegacyDatabase, getPostgresServiceName, isAdvancedDatabase, isLegacyDatabase, isPostgresAddon, } from './internal/postgres-utils.js';
@@ -8,3 +8,4 @@ export async function listCredentials(ctx, appIdentity, addonIdentity, options =
8
8
  export const postgresDatabaseExtensions = extendResource('data', 'postgresDatabase', ctx => ({
9
9
  listCredentials: (appIdentity, addonIdentity, options) => listCredentials(ctx, appIdentity, addonIdentity, options),
10
10
  }));
11
+ export { findLegacyDatabase, getPostgresServiceName, isAdvancedDatabase, isLegacyDatabase, isPostgresAddon, } from './internal/postgres-utils.js';
@@ -1,10 +1,10 @@
1
1
  import { extendResource } from '../../core/extend-resource.js';
2
- import { getArbitraryLegacyDB } from './internal/postgres-utils.js';
2
+ import { findLegacyDatabase } from './internal/postgres-utils.js';
3
3
  import { resolvePgDatabase } from './internal/resolve-pg-database.js';
4
4
  export async function list(ctx, appIdentity, options = {}) {
5
5
  options.signal?.throwIfAborted();
6
6
  // Schedules are app-scoped, so any legacy DB ID unlocks the app's list.
7
- const addon = await getArbitraryLegacyDB(ctx, appIdentity);
7
+ const addon = await findLegacyDatabase(ctx, appIdentity);
8
8
  return ctx.data.transferSchedule.list(addon.id);
9
9
  }
10
10
  export async function create(ctx, appIdentity, addonIdentity, body, options = {}) {
@@ -6,7 +6,7 @@ export function createMetricsClient(options = {}) {
6
6
  // Metrics is a distinct host that heroku-fetch does not model as a first-class
7
7
  // service, so we use `service: 'custom'` with an explicit base URL. Auth (bearer
8
8
  // token) is resolved by heroku-fetch exactly as for platform/data — same
9
- // HEROKU_API_KEY / netrc source, shared across services on one SDK instance.
9
+ // HEROKU_API_KEY / explicit-token source, shared across services on one SDK instance.
10
10
  //
11
11
  // Unlike platform/data, `service: 'custom'` sends no default Accept header, so we
12
12
  // set `application/vnd.heroku+json; version=3` explicitly to match what meetas
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroku/sdk",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Heroku SDK",
5
5
  "author": "Heroku",
6
6
  "type": "module",
@@ -154,7 +154,7 @@
154
154
  "vitest": "^4.1.4"
155
155
  },
156
156
  "dependencies": {
157
- "@heroku/heroku-fetch": "^0.1.4",
157
+ "@heroku/heroku-fetch": "^0.2.0",
158
158
  "@heroku/types": "^5.0.0",
159
159
  "debug": "^4.4.0"
160
160
  }