@heroku/sdk 0.11.0 → 0.11.2

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 (66) hide show
  1. package/dist/core/extensions-proxy.js +51 -1
  2. package/dist/core/with-signal.d.ts +19 -0
  3. package/dist/core/with-signal.js +20 -0
  4. package/dist/resources/data/database.js +19 -6
  5. package/dist/resources/data/internal/postgres-utils.d.ts +10 -6
  6. package/dist/resources/data/internal/postgres-utils.js +11 -7
  7. package/dist/resources/data/internal/resolve-pg-database.d.ts +24 -21
  8. package/dist/resources/data/internal/resolve-pg-database.js +148 -85
  9. package/dist/resources/data/maintenance.js +4 -1
  10. package/dist/resources/data/postgres-database.d.ts +1 -0
  11. package/dist/resources/data/postgres-database.js +5 -1
  12. package/dist/resources/data/redis/resolve-by-app.js +2 -1
  13. package/dist/resources/data/redis/wait-for-ready.d.ts +1 -1
  14. package/dist/resources/data/redis/wait-for-ready.js +3 -2
  15. package/dist/resources/data/transfer/capture-and-wait.js +4 -1
  16. package/dist/resources/data/transfer/restore-and-wait.js +4 -1
  17. package/dist/resources/data/transfer/wait-for-transfer.js +8 -1
  18. package/dist/resources/data/transfer-schedule.js +12 -5
  19. package/dist/resources/extensions/platform.d.ts +3 -0
  20. package/dist/resources/extensions/platform.js +3 -0
  21. package/dist/resources/platform/account.d.ts +22 -0
  22. package/dist/resources/platform/account.js +34 -0
  23. package/dist/resources/platform/add-on/create-and-wait.js +5 -1
  24. package/dist/resources/platform/add-on/describe.js +5 -2
  25. package/dist/resources/platform/add-on/destroy-and-wait.js +2 -3
  26. package/dist/resources/platform/add-on/list-plans.js +8 -2
  27. package/dist/resources/platform/add-on/resolve.js +5 -1
  28. package/dist/resources/platform/add-on/upgrade.js +5 -2
  29. package/dist/resources/platform/add-on/wait-for-provisioning.js +4 -1
  30. package/dist/resources/platform/add-on-attachment/resolve.js +2 -3
  31. package/dist/resources/platform/app/create-and-setup.d.ts +3 -4
  32. package/dist/resources/platform/app/create-and-setup.js +5 -5
  33. package/dist/resources/platform/app/diff.js +2 -3
  34. package/dist/resources/platform/app/index.js +9 -3
  35. package/dist/resources/platform/app/info.d.ts +4 -0
  36. package/dist/resources/platform/app/info.js +21 -7
  37. package/dist/resources/platform/app/transfer.js +2 -1
  38. package/dist/resources/platform/app/wait-for-acm-certificates.js +2 -1
  39. package/dist/resources/platform/container/ensure-container-stack.js +2 -1
  40. package/dist/resources/platform/container/release-images.js +2 -1
  41. package/dist/resources/platform/container/remove-process-types.js +2 -1
  42. package/dist/resources/platform/delinquency.d.ts +23 -0
  43. package/dist/resources/platform/delinquency.js +41 -0
  44. package/dist/resources/platform/domain/clear-custom-domains.js +2 -1
  45. package/dist/resources/platform/domain/create-and-wait.js +2 -1
  46. package/dist/resources/platform/domain/wait-for-ready.js +2 -1
  47. package/dist/resources/platform/dyno/exec.js +4 -3
  48. package/dist/resources/platform/dyno/index.js +6 -3
  49. package/dist/resources/platform/dyno/run.js +2 -1
  50. package/dist/resources/platform/dyno/wait-for-info.js +2 -1
  51. package/dist/resources/platform/dyno/wait-for-release.js +2 -1
  52. package/dist/resources/platform/log-session/index.js +6 -1
  53. package/dist/resources/platform/pipeline/coupling.js +5 -2
  54. package/dist/resources/platform/pipeline/index.js +4 -1
  55. package/dist/resources/platform/pipeline/promotion.js +15 -7
  56. package/dist/resources/platform/review-app-config/resolve-repo-name.js +4 -3
  57. package/dist/resources/platform/sni-endpoint/create-and-associate.js +2 -1
  58. package/dist/resources/platform/space/wait-for-allocated.js +2 -4
  59. package/dist/resources/platform/team.d.ts +22 -0
  60. package/dist/resources/platform/team.js +34 -0
  61. package/dist/resources/platform/telemetry-drain/remove-drains-for-target.js +2 -1
  62. package/dist/resources/platform/test-run/wait-for-state.js +2 -1
  63. package/dist/resources/platform/vpn-connection/wait-for-active.js +2 -1
  64. package/dist/utils/is-abort-error.d.ts +11 -0
  65. package/dist/utils/is-abort-error.js +14 -0
  66. package/package.json +1 -1
@@ -1,5 +1,55 @@
1
1
  import createDebug from 'debug';
2
2
  const debug = createDebug('heroku:sdk:extensions');
3
+ /**
4
+ * Re-derive the extension context so that an operation-wide cancellation set
5
+ * via `sdk.platform.withOptions({signal})` also reaches the clients its
6
+ * extensions issue requests through `ctx`.
7
+ *
8
+ * Extension factories close over `ctx.*` (e.g. `ctx.platform`) to issue their
9
+ * own — often nested, sometimes cross-service — requests. Without this, a signal
10
+ * set on a service client would reach generated route methods but never the
11
+ * requests an extension method makes through `ctx`, so cancellation could not
12
+ * stop the work an extension started. The derivation is applied to *every*
13
+ * service client in `ctx` because cancellation is operation-wide and extensions
14
+ * cross services (e.g. a `data` extension polls through `ctx.platform`).
15
+ *
16
+ * Only the operation-wide `signal` / `timeout` are propagated. A `withHeaders`
17
+ * call, and the `headers` / `body` fields of a `withOptions` call, are
18
+ * endpoint-specific and must not ride cross-service onto unrelated requests
19
+ * (e.g. an `Accept-Expansion` header a sibling endpoint rejects), so they stay
20
+ * on the routes client the caller scoped and are not re-derived here.
21
+ *
22
+ * `withOptions` returns a fresh client without mutating the original, and the
23
+ * derived clients are memoized per key, so sibling derived clients — and the
24
+ * base client — stay isolated from one another.
25
+ */
26
+ function deriveCtx(ctx, method, args) {
27
+ if (method !== 'withOptions')
28
+ return ctx;
29
+ const options = args[0];
30
+ const scoped = {};
31
+ if (options?.signal !== undefined)
32
+ scoped.signal = options.signal;
33
+ if (options?.timeout !== undefined)
34
+ scoped.timeout = options.timeout;
35
+ if (Object.keys(scoped).length === 0)
36
+ return ctx;
37
+ const cache = new Map();
38
+ const derived = {};
39
+ for (const key of Object.keys(ctx)) {
40
+ Object.defineProperty(derived, key, {
41
+ enumerable: true,
42
+ get() {
43
+ if (!cache.has(key)) {
44
+ const client = ctx[key];
45
+ cache.set(key, typeof client?.withOptions === 'function' ? client.withOptions(scoped) : client);
46
+ }
47
+ return cache.get(key);
48
+ },
49
+ });
50
+ }
51
+ return derived;
52
+ }
3
53
  export function mergeExtensions(client, extensions, ctx) {
4
54
  const methodsByResource = new Map();
5
55
  for (const ext of extensions) {
@@ -34,7 +84,7 @@ export function mergeExtensions(client, extensions, ctx) {
34
84
  }
35
85
  return (...args) => {
36
86
  const rewrapped = inner.apply(target, args);
37
- return mergeExtensions(rewrapped, extensions, ctx);
87
+ return mergeExtensions(rewrapped, extensions, deriveCtx(ctx, resourceKey, args));
38
88
  };
39
89
  }
40
90
  const extMethods = methodsByResource.get(resourceKey);
@@ -0,0 +1,19 @@
1
+ import type { RoutesClientExtras } from './create-client.js';
2
+ /**
3
+ * Bind a cancellation signal to a routes client so the requests it issues
4
+ * abort in flight.
5
+ *
6
+ * Extension methods receive their signal as a per-call option (e.g.
7
+ * `addOn.createAndWait(app, body, {signal})`) rather than through the client,
8
+ * so the signal reaches `wait()`/`throwIfAborted()` checkpoints but not the
9
+ * in-flight request unless it's pushed onto the client. This returns a derived,
10
+ * signal-bearing client (the original is never mutated) — or the client
11
+ * unchanged when no signal is supplied, so a signal-less call stays a no-op.
12
+ *
13
+ * Signals do not combine: `withOptions` is last-wins, so binding a per-call
14
+ * signal here replaces any signal already set on the client (e.g. a
15
+ * session-level one) for the requests this derived client issues. A caller that
16
+ * needs both to abort the request must combine them itself (e.g.
17
+ * `AbortSignal.any([sessionSignal, callSignal])`) before passing one in.
18
+ */
19
+ export declare function withSignal<T>(client: RoutesClientExtras<T> & T, signal?: AbortSignal): RoutesClientExtras<T> & T;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Bind a cancellation signal to a routes client so the requests it issues
3
+ * abort in flight.
4
+ *
5
+ * Extension methods receive their signal as a per-call option (e.g.
6
+ * `addOn.createAndWait(app, body, {signal})`) rather than through the client,
7
+ * so the signal reaches `wait()`/`throwIfAborted()` checkpoints but not the
8
+ * in-flight request unless it's pushed onto the client. This returns a derived,
9
+ * signal-bearing client (the original is never mutated) — or the client
10
+ * unchanged when no signal is supplied, so a signal-less call stays a no-op.
11
+ *
12
+ * Signals do not combine: `withOptions` is last-wins, so binding a per-call
13
+ * signal here replaces any signal already set on the client (e.g. a
14
+ * session-level one) for the requests this derived client issues. A caller that
15
+ * needs both to abort the request must combine them itself (e.g.
16
+ * `AbortSignal.any([sessionSignal, callSignal])`) before passing one in.
17
+ */
18
+ export function withSignal(client, signal) {
19
+ return signal ? client.withOptions({ signal }) : client;
20
+ }
@@ -1,34 +1,47 @@
1
1
  import { extendResource } from '../../core/extend-resource.js';
2
+ import { withSignal } from '../../core/with-signal.js';
2
3
  import { resolvePgDatabase } from './internal/resolve-pg-database.js';
3
4
  export async function describe(ctx, appIdentity, addonIdentity, options = {}) {
4
5
  options.signal?.throwIfAborted();
5
6
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
6
- return ctx.data.database.info(addon.id);
7
+ // Bind the signal so the request aborts in flight, not just during resolution.
8
+ const data = withSignal(ctx.data, options.signal);
9
+ return data.database.info(addon.id);
7
10
  }
8
11
  export async function upgradeWaitStatus(ctx, appIdentity, addonIdentity, options = {}) {
9
12
  options.signal?.throwIfAborted();
10
13
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
11
- return ctx.data.database.upgradeWaitStatus(addon.id);
14
+ // Bind the signal so the request aborts in flight, not just during resolution.
15
+ const data = withSignal(ctx.data, options.signal);
16
+ return data.database.upgradeWaitStatus(addon.id);
12
17
  }
13
18
  export async function dryRunUpgrade(ctx, appIdentity, addonIdentity, body = {}, options = {}) {
14
19
  options.signal?.throwIfAborted();
15
20
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
16
- return ctx.data.database.dryRunUpgrade(addon.id, body);
21
+ // Bind the signal so the request aborts in flight, not just during resolution.
22
+ const data = withSignal(ctx.data, options.signal);
23
+ return data.database.dryRunUpgrade(addon.id, body);
17
24
  }
18
25
  export async function runUpgrade(ctx, appIdentity, addonIdentity, body = {}, options = {}) {
19
26
  options.signal?.throwIfAborted();
20
27
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
21
- return ctx.data.database.runUpgrade(addon.id, body);
28
+ // Bind the signal so the request aborts in flight, not just during resolution.
29
+ const data = withSignal(ctx.data, options.signal);
30
+ return data.database.runUpgrade(addon.id, body);
22
31
  }
23
32
  export async function prepareUpgrade(ctx, appIdentity, addonIdentity, body = {}, options = {}) {
24
33
  options.signal?.throwIfAborted();
25
34
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
26
- return ctx.data.database.prepareUpgrade(addon.id, body);
35
+ // Bind the signal so the request aborts in flight, not just during resolution.
36
+ const data = withSignal(ctx.data, options.signal);
37
+ return data.database.prepareUpgrade(addon.id, body);
27
38
  }
28
39
  export async function cancelUpgrade(ctx, appIdentity, addonIdentity, options = {}) {
29
40
  options.signal?.throwIfAborted();
30
41
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
31
- return ctx.data.database.cancelUpgrade(addon.id);
42
+ // Bind the signal so the request aborts in flight, not just during resolution.
43
+ const data = withSignal(ctx.data, options.signal);
44
+ return data.database.cancelUpgrade(addon.id);
32
45
  }
33
46
  export const databaseExtensions = extendResource('data', 'database', ctx => ({
34
47
  cancelUpgrade: (appIdentity, addonIdentity, options) => cancelUpgrade(ctx, appIdentity, addonIdentity, options),
@@ -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,198 @@
1
1
  import { NotFoundError } from '@heroku/heroku-fetch';
2
2
  import createDebug from 'debug';
3
+ import { withSignal } from '../../../core/with-signal.js';
3
4
  import { AddonAttachmentAmbiguousError, AddonAttachmentNotFoundError, resolveAddonAttachment } from '../../platform/add-on-attachment/resolve.js';
4
- import { getAddonService, isPostgresAddon } from './postgres-utils.js';
5
+ import { getPostgresServiceName, isPostgresAddonInternal } from './postgres-utils.js';
5
6
  const debug = createDebug('heroku:sdk:resources:database');
6
7
  const DEFAULT_PG_ATTACHMENT = 'DATABASE_URL';
7
8
  /**
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.
9
+ * Thrown by `resolvePgDatabase` when a database cannot be resolved: the app
10
+ * has no Postgres databases, the attachment is not found, or the matched
11
+ * attachment is not the configured Postgres service.
11
12
  */
12
13
  export class PgDatabaseNotFoundError extends Error {
13
- constructor(message, options) {
14
- super(message, options);
15
- this.name = 'PgDatabaseNotFoundError';
16
- }
14
+ name = 'PgDatabaseNotFoundError';
17
15
  }
18
16
  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) {
17
+ const { includeAttachment, input, signal, ...rest } = options;
18
+ const databaseReference = input || DEFAULT_PG_ATTACHMENT;
19
+ const { app: parentApp, attachmentId } = parseDatabaseReference(databaseReference, appIdentity);
20
+ if (!parentApp) {
25
21
  throw new Error('resolvePgDatabase requires a non-empty app identity.');
26
22
  }
27
- debug('resolve input=%s addon=%s app=%s', input ?? '<default>', addon, app);
23
+ debug('resolve input=%s attachment=%s app=%s', input ?? '<default>', attachmentId, parentApp);
28
24
  let attachment;
29
25
  try {
30
- attachment = await resolveAddonAttachment(ctx, app, addon, { ...rest, addonService: getAddonService() });
26
+ attachment = await resolveAddonAttachment(ctx, parentApp, attachmentId, { ...rest, addonService: getPostgresServiceName(), signal });
31
27
  }
32
28
  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];
29
+ attachment = await attachmentResolutionFallback(ctx, parentApp, attachmentId, error, signal);
30
+ }
31
+ return includeAttachment ? { addon: attachment.addon, attachment } : attachment.addon;
32
+ }
33
+ /**
34
+ * Fallback when `resolveAddonAttachment` throws. Returns an attachment when the failure
35
+ * can be recovered from:
36
+ * - `NotFoundError` or `AddonAttachmentNotFoundError` → list the app's Postgres attachments.
37
+ * An empty list throws `PgDatabaseNotFoundError`. Otherwise treat `attachmentId` as a config
38
+ * var (adding `_URL` if missing) and keep every attachment whose own `_URL` value equals it.
39
+ * Those matches already share one `_URL` value by construction, so only a same-add-on check
40
+ * is needed before returning the first one. No matches, or matches that span more than one
41
+ * add-on, throws `PgDatabaseNotFoundError`.
42
+ * - `AddonAttachmentAmbiguousError` → collapse `error.matches` to one attachment via
43
+ * `equivalentAttachment` when they're the same database (same add-on, same attachment app,
44
+ * and the same non-empty `_URL` value fetched from the app's config vars). Otherwise rethrow.
45
+ * - any other error → rethrow.
46
+ */
47
+ async function attachmentResolutionFallback(ctx, appIdentity, attachmentId, error, signal) {
48
+ if (isAttachmentNotFoundError(error)) {
49
+ const attachments = await listPostgresAttachments(ctx, appIdentity, attachmentId, error, signal);
50
+ if (attachments.length === 0) {
51
+ throw new PgDatabaseNotFoundError(`${appIdentity} has no ${getPostgresServiceName()} databases`, { cause: error });
54
52
  }
55
- else {
53
+ const resolutionError = buildDatabaseNotFoundError(appIdentity, attachmentId, attachments, error);
54
+ const matches = await resolveAttachmentViaConfigVarName(ctx, appIdentity, attachmentId, attachments, resolutionError, signal);
55
+ if (matches.length === 0 || !sameAddon(matches))
56
+ throw resolutionError;
57
+ return matches[0];
58
+ }
59
+ if (error instanceof AddonAttachmentAmbiguousError) {
60
+ const equivalent = await equivalentAttachment(ctx, error.matches, error, signal);
61
+ if (!equivalent)
56
62
  throw error;
57
- }
63
+ return equivalent;
58
64
  }
59
- return includeAttachment ? { addon: attachment.addon, attachment } : attachment.addon;
65
+ throw error;
66
+ }
67
+ /**
68
+ * Used by `attachmentResolutionFallback` to collapse ambiguous matches to the first
69
+ * attachment when all matches target the same database: same add-on, same attachment
70
+ * app, and the same non-empty `_URL` value. Returns `undefined` when the matches are
71
+ * not equivalent or the first match has no `_URL` value. `matches` must be non-empty.
72
+ */
73
+ async function equivalentAttachment(ctx, matches, error, signal) {
74
+ const [first] = matches;
75
+ if (!sameAddon(matches))
76
+ return undefined;
77
+ const appConfig = await listAppConfigVars(ctx, first.app.name, error, signal);
78
+ const firstUrlValue = getUrlConfigVarValue(first, appConfig);
79
+ if (!firstUrlValue)
80
+ return undefined;
81
+ const sameDatabaseUrl = matches.every(match => getUrlConfigVarValue(match, appConfig) === firstUrlValue);
82
+ return sameDatabaseUrl ? first : undefined;
60
83
  }
61
- async function buildDatabaseNotFoundError(ctx, appIdentity, attachmentId, error, signal) {
84
+ /**
85
+ * Used by `attachmentResolutionFallback` to list an app's config vars. An aborted
86
+ * signal throws the abort error. Any other listing failure throws `error` (the
87
+ * resolution error for this attempt).
88
+ */
89
+ async function listAppConfigVars(ctx, appIdentity, error, signal) {
90
+ try {
91
+ return await withSignal(ctx.platform, 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 withSignal(ctx.platform.withHeaders({ 'Accept-Inclusion': 'addon:plan,config_vars' }), signal).addOnAttachment.listByApp(appIdentity);
65
109
  }
66
110
  catch (listError) {
67
111
  signal?.throwIfAborted();
68
112
  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.
113
+ // Listing attachments 404s only when the app does not exist, which would
114
+ // also be the reason for the attachment resolution failure. Therefore,
115
+ // we surface this error specifically.
73
116
  throw new PgDatabaseNotFoundError(`Couldn't find that app: ${appIdentity}`, { cause: listError });
74
117
  }
75
118
  debug('listing attachments failed while building not-found error: %O', listError);
76
119
  throw new PgDatabaseNotFoundError(`Could not resolve ${stripUrlSuffix(attachmentId)} on ${appIdentity}`, { cause: error });
77
120
  }
78
- if (attachments.length === 0) {
79
- return new PgDatabaseNotFoundError(`${appIdentity} has no ${getAddonService()} databases`, { cause: error });
80
- }
121
+ return attachments.filter(attachment => isPostgresAddonInternal(attachment.addon));
122
+ }
123
+ /**
124
+ * Used by `attachmentResolutionFallback` to resolve attachments via app config vars
125
+ * when no matches were found. Treats `attachmentId` as a config var name and
126
+ * uses its value to filter the attachments. Returns an empty array if the
127
+ * config var value is not found.
128
+ */
129
+ async function resolveAttachmentViaConfigVarName(ctx, appIdentity, attachmentId, attachments, error, signal) {
130
+ const appConfig = await listAppConfigVars(ctx, appIdentity, error, signal);
131
+ const inputConfigVarName = attachmentId.endsWith('_URL') ? attachmentId : `${attachmentId}_URL`;
132
+ const inputConfigVarValue = appConfig[inputConfigVarName];
133
+ if (!inputConfigVarValue)
134
+ return [];
135
+ return attachments.filter(attachment => getUrlConfigVarValue(attachment, appConfig) === inputConfigVarValue);
136
+ }
137
+ /**
138
+ * Builds a `PgDatabaseNotFoundError` when attachment resolution endpoint 404s (NotFoundError)
139
+ * or attachments did not match the add-on service filter (AddonAttachmentNotFoundError).
140
+ */
141
+ function buildDatabaseNotFoundError(appIdentity, attachmentId, attachments, error) {
81
142
  const validOptions = attachments
82
143
  .map(attachment => {
83
- const urlVarName = urlConfigVarName(attachment.config_vars);
144
+ const urlVarName = getUrlConfigVarName(attachment.config_vars);
84
145
  return urlVarName ? stripUrlSuffix(urlVarName) : attachment.name;
85
146
  })
86
147
  .join(', ');
87
148
  const databaseName = stripUrlSuffix(attachmentId);
88
149
  if (error instanceof AddonAttachmentNotFoundError) {
89
- return new PgDatabaseNotFoundError(`${databaseName} is not a ${getAddonService()} database on ${appIdentity}. Valid options are: ${validOptions}`, { cause: error });
150
+ return new PgDatabaseNotFoundError(`${databaseName} is not a ${getPostgresServiceName()} database on ${appIdentity}. Valid options are: ${validOptions}`, { cause: error });
90
151
  }
91
152
  return new PgDatabaseNotFoundError(`Unknown database: ${databaseName} on ${appIdentity}. Valid options are: ${validOptions}`, { cause: error });
92
153
  }
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
154
  /**
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.
155
+ * Returns the first config var name declared on an attachment that holds its connection
156
+ * URL or `undefined` if no such config var is found.
107
157
  */
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) {
158
+ function getUrlConfigVarName(configVars) {
115
159
  return configVars.find(name => name.endsWith('_URL'));
116
160
  }
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
- });
161
+ /**
162
+ * Looks up the value of `attachment`'s own `_URL` config var in `appConfig`, or
163
+ * `undefined` if the attachment has no `_URL`-suffixed config var.
164
+ */
165
+ function getUrlConfigVarValue(attachment, appConfig) {
166
+ const urlVarName = getUrlConfigVarName(attachment.config_vars);
167
+ return urlVarName ? appConfig[urlVarName] : undefined;
128
168
  }
129
- function parseBranchReference(reference, fallbackApp) {
169
+ /**
170
+ * Returns true if `error` is a NotFoundError (attachment resolution endpoint 404) or
171
+ * AddonAttachmentNotFoundError (attachments did not match the add-on service filter).
172
+ */
173
+ function isAttachmentNotFoundError(error) {
174
+ return error instanceof AddonAttachmentNotFoundError || error instanceof NotFoundError;
175
+ }
176
+ /**
177
+ * Splits an `app::database` reference into the app and the attachment id.
178
+ * A reference with no `::` uses `appIdentity` as the app and the whole
179
+ * reference as the attachment id.
180
+ */
181
+ function parseDatabaseReference(reference, appIdentity) {
130
182
  const match = reference.match(/^(.+?)::(.+)$/);
131
183
  if (match) {
132
- return { addon: match[2], app: match[1] };
184
+ return { app: match[1], attachmentId: match[2] };
133
185
  }
134
- return { addon: reference, app: fallbackApp };
186
+ return { app: appIdentity, attachmentId: reference };
187
+ }
188
+ /** Whether every match shares the same add-on and attachment app. */
189
+ function sameAddon(matches) {
190
+ const [first] = matches;
191
+ return matches.every(match => match.addon.id === first.addon.id && match.app.id === first.app.id);
192
+ }
193
+ /**
194
+ * Removes the `_URL` suffix from a config var name if it exists.
195
+ */
196
+ function stripUrlSuffix(name) {
197
+ return name.endsWith('_URL') ? name.slice(0, -4) : name;
135
198
  }
@@ -1,9 +1,12 @@
1
1
  import { extendResource } from '../../core/extend-resource.js';
2
+ import { withSignal } from '../../core/with-signal.js';
2
3
  import { resolvePgDatabase } from './internal/resolve-pg-database.js';
3
4
  export async function info(ctx, appIdentity, addonIdentity, options = {}) {
4
5
  options.signal?.throwIfAborted();
5
6
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
6
- return ctx.data.maintenance.info(addon.id);
7
+ // Bind the signal so the request aborts in flight, not just during resolution.
8
+ const data = withSignal(ctx.data, options.signal);
9
+ return data.maintenance.info(addon.id);
7
10
  }
8
11
  export const maintenanceExtensions = extendResource('data', 'maintenance', ctx => ({
9
12
  info: (appIdentity, addonIdentity, options) => info(ctx, appIdentity, addonIdentity, options),
@@ -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';
@@ -1,10 +1,14 @@
1
1
  import { extendResource } from '../../core/extend-resource.js';
2
+ import { withSignal } from '../../core/with-signal.js';
2
3
  import { resolvePgDatabase } from './internal/resolve-pg-database.js';
3
4
  export async function listCredentials(ctx, appIdentity, addonIdentity, options = {}) {
4
5
  options.signal?.throwIfAborted();
5
6
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
6
- return ctx.data.postgresDatabase.listCredentials(addon.id);
7
+ // Bind the signal so the request aborts in flight, not just during resolution.
8
+ const data = withSignal(ctx.data, options.signal);
9
+ return data.postgresDatabase.listCredentials(addon.id);
7
10
  }
8
11
  export const postgresDatabaseExtensions = extendResource('data', 'postgresDatabase', ctx => ({
9
12
  listCredentials: (appIdentity, addonIdentity, options) => listCredentials(ctx, appIdentity, addonIdentity, options),
10
13
  }));
14
+ export { findLegacyDatabase, getPostgresServiceName, isAdvancedDatabase, isLegacyDatabase, isPostgresAddon, } from './internal/postgres-utils.js';