@heroku/sdk 0.11.1 → 0.12.0

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 (76) hide show
  1. package/dist/core/create-client.d.ts +7 -2
  2. package/dist/core/create-client.js +5 -2
  3. package/dist/core/extend-resource.d.ts +52 -2
  4. package/dist/core/extend-resource.js +27 -0
  5. package/dist/core/extensions-proxy.js +51 -1
  6. package/dist/core/heroku-sdk.js +44 -1
  7. package/dist/core/with-signal.d.ts +19 -0
  8. package/dist/core/with-signal.js +20 -0
  9. package/dist/resources/data/database.js +19 -6
  10. package/dist/resources/data/internal/resolve-pg-database.js +4 -7
  11. package/dist/resources/data/maintenance.js +4 -1
  12. package/dist/resources/data/postgres-database.js +4 -1
  13. package/dist/resources/data/redis/resolve-by-app.js +2 -1
  14. package/dist/resources/data/redis/wait-for-ready.d.ts +1 -1
  15. package/dist/resources/data/redis/wait-for-ready.js +3 -2
  16. package/dist/resources/data/transfer/capture-and-wait.js +4 -1
  17. package/dist/resources/data/transfer/restore-and-wait.js +4 -1
  18. package/dist/resources/data/transfer/wait-for-transfer.js +8 -1
  19. package/dist/resources/data/transfer-schedule.js +10 -3
  20. package/dist/resources/extensions/platform.d.ts +3 -0
  21. package/dist/resources/extensions/platform.js +3 -0
  22. package/dist/resources/platform/account.d.ts +22 -0
  23. package/dist/resources/platform/account.js +34 -0
  24. package/dist/resources/platform/add-on/create-and-wait.js +5 -1
  25. package/dist/resources/platform/add-on/describe.js +5 -2
  26. package/dist/resources/platform/add-on/destroy-and-wait.js +2 -3
  27. package/dist/resources/platform/add-on/list-plans.js +8 -2
  28. package/dist/resources/platform/add-on/resolve.js +5 -1
  29. package/dist/resources/platform/add-on/upgrade.js +5 -2
  30. package/dist/resources/platform/add-on/wait-for-provisioning.js +4 -1
  31. package/dist/resources/platform/add-on-attachment/resolve.js +2 -3
  32. package/dist/resources/platform/app/create-and-setup.d.ts +3 -4
  33. package/dist/resources/platform/app/create-and-setup.js +5 -5
  34. package/dist/resources/platform/app/diff.js +2 -3
  35. package/dist/resources/platform/app/index.js +9 -3
  36. package/dist/resources/platform/app/info.d.ts +4 -0
  37. package/dist/resources/platform/app/info.js +21 -7
  38. package/dist/resources/platform/app/transfer.js +2 -1
  39. package/dist/resources/platform/app/wait-for-acm-certificates.js +2 -1
  40. package/dist/resources/platform/container/ensure-container-stack.js +2 -1
  41. package/dist/resources/platform/container/release-images.js +2 -1
  42. package/dist/resources/platform/container/remove-process-types.js +2 -1
  43. package/dist/resources/platform/delinquency.d.ts +23 -0
  44. package/dist/resources/platform/delinquency.js +41 -0
  45. package/dist/resources/platform/domain/clear-custom-domains.js +2 -1
  46. package/dist/resources/platform/domain/create-and-wait.js +2 -1
  47. package/dist/resources/platform/domain/wait-for-ready.js +2 -1
  48. package/dist/resources/platform/dyno/exec.d.ts +12 -2
  49. package/dist/resources/platform/dyno/exec.js +27 -12
  50. package/dist/resources/platform/dyno/index.js +9 -6
  51. package/dist/resources/platform/dyno/list-extended.d.ts +7 -1
  52. package/dist/resources/platform/dyno/list-extended.js +8 -3
  53. package/dist/resources/platform/dyno/run.d.ts +1 -1
  54. package/dist/resources/platform/dyno/run.js +5 -3
  55. package/dist/resources/platform/dyno/wait-for-info.js +2 -1
  56. package/dist/resources/platform/dyno/wait-for-release.js +2 -1
  57. package/dist/resources/platform/log-session/index.d.ts +1 -1
  58. package/dist/resources/platform/log-session/index.js +12 -11
  59. package/dist/resources/platform/pipeline/coupling.js +5 -2
  60. package/dist/resources/platform/pipeline/index.d.ts +2 -2
  61. package/dist/resources/platform/pipeline/index.js +6 -4
  62. package/dist/resources/platform/pipeline/promotion.d.ts +1 -1
  63. package/dist/resources/platform/pipeline/promotion.js +22 -18
  64. package/dist/resources/platform/review-app-config/resolve-repo-name.js +4 -3
  65. package/dist/resources/platform/sni-endpoint/create-and-associate.js +2 -1
  66. package/dist/resources/platform/space/wait-for-allocated.js +2 -4
  67. package/dist/resources/platform/team.d.ts +22 -0
  68. package/dist/resources/platform/team.js +34 -0
  69. package/dist/resources/platform/telemetry-drain/remove-drains-for-target.js +2 -1
  70. package/dist/resources/platform/test-run/wait-for-state.js +2 -1
  71. package/dist/resources/platform/vpn-connection/wait-for-active.js +2 -1
  72. package/dist/utils/is-abort-error.d.ts +11 -0
  73. package/dist/utils/is-abort-error.js +14 -0
  74. package/dist/version.d.ts +1 -0
  75. package/dist/version.js +2 -0
  76. package/package.json +9 -4
@@ -7,7 +7,7 @@ type RoutesModule = Record<string, Record<string, RouteDefinition>>;
7
7
  * and `stream` are deliberately excluded — those belong on the
8
8
  * individual route call, not on the client wrapper.
9
9
  */
10
- export type StickyRouteOptions = Pick<RequestOptions, 'headers' | 'signal' | 'timeout'> & {
10
+ export type StickyRouteOptions = Pick<RequestOptions, 'headers' | 'reauthorize' | 'signal' | 'timeout'> & {
11
11
  /**
12
12
  * Request body applied to write calls on the derived client. Primarily
13
13
  * for methods whose route metadata lacks `hasRequestBody` (e.g. DELETE
@@ -30,7 +30,12 @@ export type RoutesClientExtras<T> = {
30
30
  * Returns a same-shaped client where each route call applies the
31
31
  * provided options. Caller-supplied headers override the api-client's
32
32
  * defaults; subsequent `withOptions` calls layer headers and replace
33
- * signal / timeout.
33
+ * signal / timeout / reauthorize.
34
+ *
35
+ * Pass `reauthorize: false` to opt the derived client's calls out of the
36
+ * client-level 401 refresh (see {@link RequestOptions.reauthorize}) — for
37
+ * call sites where a 401 means something other than an expired Heroku
38
+ * credential and must not drive a login.
34
39
  *
35
40
  * The original client is not mutated.
36
41
  */
@@ -11,12 +11,15 @@ function mergeStickyOptions(base, next) {
11
11
  if (next.headers || base?.headers) {
12
12
  merged.headers = { ...base?.headers, ...next.headers };
13
13
  }
14
- // signal, timeout, and body are not additive — last wins, but only if
15
- // the caller actually set them. Otherwise the previous value persists.
14
+ // signal, timeout, reauthorize, and body are not additive — last wins,
15
+ // but only if the caller actually set them. Otherwise the previous value
16
+ // persists.
16
17
  if (next.signal !== undefined)
17
18
  merged.signal = next.signal;
18
19
  if (next.timeout !== undefined)
19
20
  merged.timeout = next.timeout;
21
+ if (next.reauthorize !== undefined)
22
+ merged.reauthorize = next.reauthorize;
20
23
  if (next.body !== undefined)
21
24
  merged.body = next.body;
22
25
  return merged;
@@ -1,3 +1,5 @@
1
+ import type { HerokuApiClientOptions } from '@heroku/heroku-fetch';
2
+ import { HerokuApiClient } from '@heroku/heroku-fetch';
1
3
  import type { DashboardBackendClient } from '../services/dashboard-backend.js';
2
4
  import type { DataClient } from '../services/data.js';
3
5
  import type { MetricsClient } from '../services/metrics.js';
@@ -14,10 +16,58 @@ type BaseResourceCtx = {
14
16
  platform: PlatformClient;
15
17
  repositories: RepositoriesClient;
16
18
  };
17
- export type ResourceCtx = BaseResourceCtx & {
19
+ export type ResourceCtx = {
20
+ /**
21
+ * Build a raw {@link HerokuApiClient} that inherits the SDK's resolved
22
+ * client options for the `inheritFrom` service bundle (token,
23
+ * `userAgent`/headers, `signal`, `timeout`, `warn`, …), with `overrides`
24
+ * applied last.
25
+ *
26
+ * `inheritFrom` selects *which resolved option bundle to start from* — not
27
+ * the wire service; pass `service` in `overrides` to redirect the client to
28
+ * a different host and auth behavior. The returned client is raw, NOT the
29
+ * managed service client: it does not carry that client's `defaultAccept`
30
+ * (e.g. the platform's `version=3.sdk`) or routes proxy, so callers issue
31
+ * `get`/`post`/`stream` directly and set `Accept` per call.
32
+ *
33
+ * When `overrides.service === 'custom'` the inherited bundle is narrowed to
34
+ * identity and cancellation only (never auth/routing), so a custom stream
35
+ * host can't receive the Heroku token, a `reauthorize` login callback, or
36
+ * consumer headers. Prefer {@link makeCustomHostClient} for that case rather
37
+ * than assembling the overrides by hand.
38
+ *
39
+ * Optional for compatibility with existing extension contexts; prefer
40
+ * {@link makeCtxClient} at call sites so a context predating this stays
41
+ * on the pre-threading behavior.
42
+ *
43
+ * TODO(next major): make this required on ResourceCtx and drop the
44
+ * {@link makeCtxClient} fallback once external hand-built contexts are no
45
+ * longer supported.
46
+ */
47
+ makeClient?: (inheritFrom: ServiceName, overrides?: HerokuApiClientOptions) => HerokuApiClient;
18
48
  /** Available to SDK-provided extensions; optional for compatibility with existing extension contexts. */
19
49
  repositoriesApi?: RepositoriesApiClient;
20
- };
50
+ } & BaseResourceCtx;
51
+ /**
52
+ * Build a raw {@link HerokuApiClient} from a resource context, inheriting the
53
+ * SDK's resolved client options for the `inheritFrom` service bundle
54
+ * (identity, `signal`, `warn`, token, …) with `overrides` applied last. Falls
55
+ * back to a bare client carrying only `overrides` when the context predates
56
+ * {@link ResourceCtx.makeClient} (e.g. a hand-built test ctx, or a standalone
57
+ * call with no SDK behind it), preserving the pre-threading behavior exactly.
58
+ */
59
+ export declare function makeCtxClient(ctx: Pick<ResourceCtx, 'makeClient'>, inheritFrom: ServiceName, overrides?: HerokuApiClientOptions): HerokuApiClient;
60
+ /**
61
+ * Build a raw {@link HerokuApiClient} for a non-Heroku-credential host — a
62
+ * custom stream/API host the routes-generated clients don't cover (logplex,
63
+ * busl, exec-manager). Inherits only the SDK's identity and cancellation from
64
+ * the platform option bundle; forces `service: 'custom'`, the given `baseUrl`,
65
+ * and an empty `token`, and drops every auth/routing option (`reauthorize`,
66
+ * `headers`, `hooks`, `twoFactor`, `region`, …) so none crosses to the
67
+ * third-party origin. `overrides` the call site explicitly sets still apply —
68
+ * use them for a per-host header or timeout, not to smuggle inherited auth.
69
+ */
70
+ export declare function makeCustomHostClient(ctx: Pick<ResourceCtx, 'makeClient'>, baseUrl: string, overrides?: Omit<HerokuApiClientOptions, 'baseUrl' | 'service' | 'token'>): HerokuApiClient;
21
71
  export type ResourceMethods = Record<string, (...args: any[]) => any>;
22
72
  export type ResourceExtension<S extends ServiceName = ServiceName, R extends string = string, M extends ResourceMethods = ResourceMethods> = {
23
73
  factory: (ctx: ResourceCtx) => M;
@@ -1,3 +1,30 @@
1
+ import { HerokuApiClient } from '@heroku/heroku-fetch';
2
+ /**
3
+ * Build a raw {@link HerokuApiClient} from a resource context, inheriting the
4
+ * SDK's resolved client options for the `inheritFrom` service bundle
5
+ * (identity, `signal`, `warn`, token, …) with `overrides` applied last. Falls
6
+ * back to a bare client carrying only `overrides` when the context predates
7
+ * {@link ResourceCtx.makeClient} (e.g. a hand-built test ctx, or a standalone
8
+ * call with no SDK behind it), preserving the pre-threading behavior exactly.
9
+ */
10
+ export function makeCtxClient(ctx, inheritFrom, overrides = {}) {
11
+ return ctx.makeClient ? ctx.makeClient(inheritFrom, overrides) : new HerokuApiClient(overrides);
12
+ }
13
+ /**
14
+ * Build a raw {@link HerokuApiClient} for a non-Heroku-credential host — a
15
+ * custom stream/API host the routes-generated clients don't cover (logplex,
16
+ * busl, exec-manager). Inherits only the SDK's identity and cancellation from
17
+ * the platform option bundle; forces `service: 'custom'`, the given `baseUrl`,
18
+ * and an empty `token`, and drops every auth/routing option (`reauthorize`,
19
+ * `headers`, `hooks`, `twoFactor`, `region`, …) so none crosses to the
20
+ * third-party origin. `overrides` the call site explicitly sets still apply —
21
+ * use them for a per-host header or timeout, not to smuggle inherited auth.
22
+ */
23
+ export function makeCustomHostClient(ctx, baseUrl, overrides = {}) {
24
+ return makeCtxClient(ctx, 'platform', {
25
+ ...overrides, baseUrl, service: 'custom', token: '',
26
+ });
27
+ }
1
28
  export function extendResource(service, resource, factory) {
2
29
  return { factory, resource, service };
3
30
  }
@@ -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);
@@ -1,3 +1,4 @@
1
+ import { HerokuApiClient } from '@heroku/heroku-fetch';
1
2
  import { createDashboardBackendClient } from '../services/dashboard-backend.js';
2
3
  import { createDataClient } from '../services/data.js';
3
4
  import { createMetricsClient } from '../services/metrics.js';
@@ -5,7 +6,17 @@ import { createNotificationsClient } from '../services/notifications.js';
5
6
  import { createPlatformClient } from '../services/platform.js';
6
7
  import { createRepositoriesApiClient } from '../services/repositories-api.js';
7
8
  import { createRepositoriesClient } from '../services/repositories.js';
9
+ import { SDK_VERSION } from '../version.js';
8
10
  import { mergeExtensions } from './extensions-proxy.js';
11
+ /**
12
+ * Default `User-Agent` identity for SDK-originated traffic. {@link HerokuSDK}
13
+ * applies it as a fill-if-absent default on every service client (in its
14
+ * `#getClientOptions` merge), so requests the SDK drives stay identifiable even
15
+ * when the consumer passes no `userAgent`. A consumer-supplied
16
+ * `clientOptions.userAgent` — or a per-service override — wins. The version is
17
+ * stamped from package.json at build time; see `src/version.ts`.
18
+ */
19
+ const DEFAULT_USER_AGENT = { name: '@heroku/sdk', version: SDK_VERSION };
9
20
  function partitionByService(extensions) {
10
21
  const map = new Map();
11
22
  for (const ext of extensions) {
@@ -15,6 +26,26 @@ function partitionByService(extensions) {
15
26
  }
16
27
  return map;
17
28
  }
29
+ /**
30
+ * The options a custom-host client (`service: 'custom'`) inherits from the
31
+ * SDK's resolved bundle: identity and cancellation only. Every
32
+ * Heroku-credential-domain option — auth (`token`, `reauthorize`, `twoFactor`),
33
+ * routing (`headers`, `region`, `defaultAccept`), and consumer
34
+ * `hooks`/`onResponseMetadata` — is intentionally dropped so it never reaches a
35
+ * third-party origin (logplex, busl, exec-manager): sending the bearer token or
36
+ * a consumer `Authorization` header cross-origin would leak credentials, and
37
+ * inheriting `reauthorize` would turn a non-Heroku 401 into a spurious
38
+ * `heroku login`.
39
+ */
40
+ function pickCustomHostOptions(options) {
41
+ return {
42
+ ...(options.debug !== undefined && { debug: options.debug }),
43
+ ...(options.signal !== undefined && { signal: options.signal }),
44
+ ...(options.timeout !== undefined && { timeout: options.timeout }),
45
+ ...(options.userAgent !== undefined && { userAgent: options.userAgent }),
46
+ ...(options.warn !== undefined && { warn: options.warn }),
47
+ };
48
+ }
18
49
  export class HerokuSDK {
19
50
  #clientOptions;
20
51
  #clientOptionsByService;
@@ -72,6 +103,7 @@ export class HerokuSDK {
72
103
  const serviceOptions = { ...serviceOverride };
73
104
  delete serviceOptions.service;
74
105
  return {
106
+ userAgent: DEFAULT_USER_AGENT,
75
107
  ...this.#clientOptions,
76
108
  ...serviceOptions,
77
109
  };
@@ -86,6 +118,17 @@ export class HerokuSDK {
86
118
  enumerable: true,
87
119
  get: () => this.#getRawData(),
88
120
  },
121
+ makeClient: {
122
+ enumerable: true,
123
+ value: (inheritFrom, overrides = {}) => {
124
+ const resolved = this.#getClientOptions(inheritFrom);
125
+ // A custom host is not the Heroku credential domain: start from
126
+ // identity/cancellation only, never the auth/routing options that
127
+ // would leak across origins or drive a re-login on a foreign 401.
128
+ const base = overrides.service === 'custom' ? pickCustomHostOptions(resolved) : resolved;
129
+ return new HerokuApiClient({ ...base, ...overrides });
130
+ },
131
+ },
89
132
  metrics: {
90
133
  enumerable: true,
91
134
  get: () => this.#getRawMetrics(),
@@ -122,7 +165,7 @@ export class HerokuSDK {
122
165
  return this.#rawMetrics;
123
166
  }
124
167
  #getRawNotifications() {
125
- this.#rawNotifications ??= createNotificationsClient(this.#clientOptions);
168
+ this.#rawNotifications ??= createNotificationsClient(this.#getClientOptions('notifications'));
126
169
  return this.#rawNotifications;
127
170
  }
128
171
  #getRawPlatform() {
@@ -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,5 +1,6 @@
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
5
  import { getPostgresServiceName, isPostgresAddonInternal } from './postgres-utils.js';
5
6
  const debug = createDebug('heroku:sdk:resources:database');
@@ -87,8 +88,7 @@ async function equivalentAttachment(ctx, matches, error, signal) {
87
88
  */
88
89
  async function listAppConfigVars(ctx, appIdentity, error, signal) {
89
90
  try {
90
- return await ctx.platform
91
- .withOptions({ signal })
91
+ return await withSignal(ctx.platform, signal)
92
92
  .configVar.infoForApp(appIdentity);
93
93
  }
94
94
  catch (configVarError) {
@@ -101,14 +101,11 @@ async function listAppConfigVars(ctx, appIdentity, error, signal) {
101
101
  * Used by `attachmentResolutionFallback` to list all attachments on an app that match the
102
102
  * add-on service filter. Throws `PgDatabaseNotFoundError` if the app does not exist
103
103
  * or the listing fails for any other reason.
104
- */
104
+ */
105
105
  async function listPostgresAttachments(ctx, appIdentity, attachmentId, error, signal) {
106
106
  let attachments;
107
107
  try {
108
- attachments = await ctx.platform
109
- .withHeaders({ 'Accept-Inclusion': 'addon:plan,config_vars' })
110
- .withOptions({ signal })
111
- .addOnAttachment.listByApp(appIdentity);
108
+ attachments = await withSignal(ctx.platform.withHeaders({ 'Accept-Inclusion': 'addon:plan,config_vars' }), signal).addOnAttachment.listByApp(appIdentity);
112
109
  }
113
110
  catch (listError) {
114
111
  signal?.throwIfAborted();
@@ -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),
@@ -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 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),
@@ -1,4 +1,5 @@
1
1
  import createDebug from 'debug';
2
+ import { withSignal } from '../../../core/with-signal.js';
2
3
  import { RedisAddonAmbiguousError, RedisAddonNotFoundError } from './errors.js';
3
4
  const debug = createDebug('heroku:sdk:resources:redis');
4
5
  const DEFAULT_REDIS_ADDON_SERVICE_PREFIX = 'heroku-redis';
@@ -19,7 +20,7 @@ const DEFAULT_REDIS_ADDON_SERVICE_PREFIX = 'heroku-redis';
19
20
  export async function resolveRedisByApp(ctx, appIdentity, options = {}) {
20
21
  options.signal?.throwIfAborted();
21
22
  const { addonServiceName = DEFAULT_REDIS_ADDON_SERVICE_PREFIX, database } = options;
22
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
23
+ const platform = withSignal(ctx.platform, options.signal);
23
24
  debug('resolveByApp app=%s service-prefix=%s database=%s', appIdentity, addonServiceName, database ?? '<any>');
24
25
  const addons = await platform.addOn.listByApp(appIdentity);
25
26
  const filter = database?.toUpperCase();
@@ -26,7 +26,7 @@ export type WaitForRedisReadyOptions = {
26
26
  * provision call resolves.
27
27
  *
28
28
  * Cancellation: the caller's `signal` is threaded into both the
29
- * in-flight request (via `withOptions`) and the poll-delay `wait()`,
29
+ * in-flight request (via `withSignal`) and the poll-delay `wait()`,
30
30
  * so aborting takes effect during either phase.
31
31
  */
32
32
  export declare function waitForRedisReady(ctx: Pick<ResourceCtx, 'data'>, nameOrId: string, options?: WaitForRedisReadyOptions): Promise<RedisWaitResult>;
@@ -1,5 +1,6 @@
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 { wait } from '../../../utils/wait.js';
4
5
  const debug = createDebug('heroku:sdk:resources:redis');
5
6
  const DEFAULT_INTERVAL_MS = 5000;
@@ -13,13 +14,13 @@ const DEFAULT_INTERVAL_MS = 5000;
13
14
  * provision call resolves.
14
15
  *
15
16
  * Cancellation: the caller's `signal` is threaded into both the
16
- * in-flight request (via `withOptions`) and the poll-delay `wait()`,
17
+ * in-flight request (via `withSignal`) and the poll-delay `wait()`,
17
18
  * so aborting takes effect during either phase.
18
19
  */
19
20
  export async function waitForRedisReady(ctx, nameOrId, options = {}) {
20
21
  const { intervalMs = DEFAULT_INTERVAL_MS, signal, timeoutMs } = options;
21
22
  signal?.throwIfAborted();
22
- const data = signal ? ctx.data.withOptions({ signal }) : ctx.data;
23
+ const data = withSignal(ctx.data, signal);
23
24
  const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
24
25
  debug('waitForReady name=%s intervalMs=%d timeoutMs=%s', nameOrId, intervalMs, timeoutMs ?? '<none>');
25
26
  while (true) {
@@ -1,11 +1,14 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { resolvePgDatabase } from '../internal/resolve-pg-database.js';
2
3
  import { waitForTransfer } from './wait-for-transfer.js';
3
4
  export async function captureAndWait(ctx, appIdentity, addonIdentity, options = {}) {
4
5
  const { capturePoller, signal, waitPoller } = options;
5
6
  signal?.throwIfAborted();
6
7
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
8
+ // Bind the signal so the create request aborts in flight, not just the wait.
9
+ const data = withSignal(ctx.data, signal);
7
10
  capturePoller?.onStart?.(addon);
8
- const backup = await ctx.data.backup.create(addon.id);
11
+ const backup = await data.backup.create(addon.id);
9
12
  capturePoller?.onStop?.(addon);
10
13
  waitPoller?.onStart?.(backup);
11
14
  const transfer = await waitForTransfer(ctx, addon.app.name, backup.uuid, options);
@@ -1,12 +1,15 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { resolvePgDatabase } from '../internal/resolve-pg-database.js';
2
3
  import { waitForTransfer } from './wait-for-transfer.js';
3
4
  export async function restoreAndWait(ctx, appIdentity, addonIdentity, backupUrl, options = {}) {
4
5
  const { extensions, restorePoller, signal, waitPoller } = options;
5
6
  signal?.throwIfAborted();
6
7
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
8
+ // Bind the signal so the create request aborts in flight, not just the wait.
9
+ const data = withSignal(ctx.data, signal);
7
10
  restorePoller?.onStart?.(addon);
8
11
  // eslint-disable-next-line camelcase
9
- const restore = await ctx.data.restore.create(addon.id, { backup_url: backupUrl, extensions });
12
+ const restore = await data.restore.create(addon.id, { backup_url: backupUrl, extensions });
10
13
  restorePoller?.onStop?.(addon);
11
14
  waitPoller?.onStart?.(restore);
12
15
  const transfer = await waitForTransfer(ctx, addon.app.name, restore.uuid, options);
@@ -1,4 +1,6 @@
1
1
  import { extendResource } from '../../../core/extend-resource.js';
2
+ import { withSignal } from '../../../core/with-signal.js';
3
+ import { isAbortError } from '../../../utils/is-abort-error.js';
2
4
  import { wait } from '../../../utils/wait.js';
3
5
  const DEFAULT_INTERVAL_MS = 3000;
4
6
  const MAX_FAILURES = 20;
@@ -46,7 +48,7 @@ export class TransferTimeoutError extends Error {
46
48
  export async function waitForTransfer(ctx, appIdentity, transferId, options = {}) {
47
49
  const { intervalMs = DEFAULT_INTERVAL_MS, onPoll, signal, timeoutMs, verbose, } = options;
48
50
  signal?.throwIfAborted();
49
- const data = signal ? ctx.data.withOptions({ signal }) : ctx.data;
51
+ const data = withSignal(ctx.data, signal);
50
52
  const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
51
53
  let failures = 0;
52
54
  let transfer;
@@ -56,6 +58,11 @@ export async function waitForTransfer(ctx, appIdentity, transferId, options = {}
56
58
  transfer = await data.transfer.infoByApp(appIdentity, transferId, { verbose });
57
59
  }
58
60
  catch (error) {
61
+ // Never retry a cancellation: a client- or caller-level abort must stop
62
+ // the poll immediately rather than burn up to MAX_FAILURES attempts.
63
+ if (signal?.aborted || isAbortError(error)) {
64
+ throw error;
65
+ }
59
66
  failures++;
60
67
  if (failures > MAX_FAILURES) {
61
68
  throw error;
@@ -1,11 +1,14 @@
1
1
  import { extendResource } from '../../core/extend-resource.js';
2
+ import { withSignal } from '../../core/with-signal.js';
2
3
  import { findLegacyDatabase } from './internal/postgres-utils.js';
3
4
  import { resolvePgDatabase } from './internal/resolve-pg-database.js';
4
5
  export async function list(ctx, appIdentity, options = {}) {
5
6
  options.signal?.throwIfAborted();
6
7
  // Schedules are app-scoped, so any legacy DB ID unlocks the app's list.
7
8
  const addon = await findLegacyDatabase(ctx, appIdentity);
8
- return ctx.data.transferSchedule.list(addon.id);
9
+ // Bind the signal so the request aborts in flight, not just during resolution.
10
+ const data = withSignal(ctx.data, options.signal);
11
+ return data.transferSchedule.list(addon.id);
9
12
  }
10
13
  export async function create(ctx, appIdentity, addonIdentity, body, options = {}) {
11
14
  options.signal?.throwIfAborted();
@@ -17,12 +20,16 @@ export async function create(ctx, appIdentity, addonIdentity, body, options = {}
17
20
  // eslint-disable-next-line camelcase
18
21
  requestBody = { ...body, schedule_name: `${attachment.name}_URL` };
19
22
  }
20
- return ctx.data.transferSchedule.create(addon.id, requestBody);
23
+ // Bind the signal so the request aborts in flight, not just during resolution.
24
+ const data = withSignal(ctx.data, options.signal);
25
+ return data.transferSchedule.create(addon.id, requestBody);
21
26
  }
22
27
  export async function del(ctx, appIdentity, addonIdentity, scheduleId, options = {}) {
23
28
  options.signal?.throwIfAborted();
24
29
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
25
- return ctx.data.transferSchedule.delete(addon.id, scheduleId);
30
+ // Bind the signal so the request aborts in flight, not just during resolution.
31
+ const data = withSignal(ctx.data, options.signal);
32
+ return data.transferSchedule.delete(addon.id, scheduleId);
26
33
  }
27
34
  export const transferScheduleExtensions = extendResource('data', 'transferSchedule', ctx => ({
28
35
  create: (appIdentity, addonIdentity, body, options) => create(ctx, appIdentity, addonIdentity, body, options),
@@ -1,7 +1,9 @@
1
+ export { accountExtensions, type AccountWithDelinquency, type GetAccountWithDelinquencyOptions } from '../platform/account.js';
1
2
  export { addOnAttachmentExtensions } from '../platform/add-on-attachment/index.js';
2
3
  export { addOnExtensions } from '../platform/add-on/index.js';
3
4
  export { appExtensions, type AppInfo, type DiffAppsOptions, type DiffRow, type PipelineCouplingDetail, } from '../platform/app/index.js';
4
5
  export { containerExtensions, type DockerReleasesBatchUpdateOpts, NotAContainerAppError, type ReleaseDockerImagesResult, type RemoveProcessTypesOpts, } from '../platform/container/index.js';
6
+ export { type DelinquencyResource, type DelinquencySchedule, formatDelinquencyWarning, } from '../platform/delinquency.js';
5
7
  export { type CreateAndWaitOptions, domainExtensions, type DomainOptions, type WaitForReadyOptions, } from '../platform/domain/index.js';
6
8
  export { DynoCrashedError, type DynoExtended, type DynoExtendedFields, dynoExtensions, DynoNotReadyError, type DynoState, enableExec, exchangeExecCredentials, type ExchangeExecCredentialsOptions, type ExecCredentials, type ExecOptions, type ExecPrereqs, execPrereqs, type ExecReservation, execStatus, type ExecStatusOptions, listExtended, type ListExtendedOptions, restartForExec, type RestartForExecOptions, runDyno, type RunDynoOptions, waitForInfo, type WaitForInfoOptions, waitForRelease, type WaitForReleaseOptions, type WaitForReleaseProgress, type WaitForReleaseResult, } from '../platform/dyno/index.js';
7
9
  export { type AttachmentAttachedEvent, type AttachmentDetachedEvent, type AttachmentUpdatedEvent, type HerokuLogEvent, logSessionExtensions, parseHerokuLogLine, type ProvisioningCompletedEvent, type ScaledToEntry, type ScaledToEvent, type StartingProcessEvent, type StateChangedEvent, } from '../platform/log-session/index.js';
@@ -12,6 +14,7 @@ export { type ResolveRepoNameOptions, reviewAppConfigExtensions } from '../platf
12
14
  export { privateToShield, shieldToPrivate } from '../platform/shield.js';
13
15
  export { createAndAssociate, type CreateAndAssociateOptions, sniEndpointExtensions } from '../platform/sni-endpoint/index.js';
14
16
  export { spaceExtensions, SpaceNotAllocatedError, SpaceNotReadyError, type SpaceOptions, type SpaceWithNat, waitForAllocated, type WaitForAllocatedOptions, } from '../platform/space/index.js';
17
+ export { type GetTeamWithDelinquencyOptions, teamExtensions, type TeamWithDelinquency } from '../platform/team.js';
15
18
  export { telemetryDrainExtensions, type TelemetryDrainOptions, type TelemetryDrainTarget } from '../platform/telemetry-drain/index.js';
16
19
  export { testRunExtensions, TestRunNotReadyError, type TestRunStatus, waitForState, type WaitForStateOptions, } from '../platform/test-run/index.js';
17
20
  export { vpnConnectionExtensions, VpnConnectionFailedError, VpnConnectionNotReadyError, type VpnConnectionOptions, waitForActive, type WaitForActiveOptions, } from '../platform/vpn-connection/index.js';
@@ -1,7 +1,9 @@
1
+ export { accountExtensions } from '../platform/account.js';
1
2
  export { addOnAttachmentExtensions } from '../platform/add-on-attachment/index.js';
2
3
  export { addOnExtensions } from '../platform/add-on/index.js';
3
4
  export { appExtensions, } from '../platform/app/index.js';
4
5
  export { containerExtensions, NotAContainerAppError, } from '../platform/container/index.js';
6
+ export { formatDelinquencyWarning, } from '../platform/delinquency.js';
5
7
  export { domainExtensions, } from '../platform/domain/index.js';
6
8
  export { DynoCrashedError, dynoExtensions, DynoNotReadyError, enableExec, exchangeExecCredentials, execPrereqs, execStatus, listExtended, restartForExec, runDyno, waitForInfo, waitForRelease, } from '../platform/dyno/index.js';
7
9
  export { logSessionExtensions, parseHerokuLogLine, } from '../platform/log-session/index.js';
@@ -12,6 +14,7 @@ export { reviewAppConfigExtensions } from '../platform/review-app-config/index.j
12
14
  export { privateToShield, shieldToPrivate } from '../platform/shield.js';
13
15
  export { createAndAssociate, sniEndpointExtensions } from '../platform/sni-endpoint/index.js';
14
16
  export { spaceExtensions, SpaceNotAllocatedError, SpaceNotReadyError, waitForAllocated, } from '../platform/space/index.js';
17
+ export { teamExtensions } from '../platform/team.js';
15
18
  export { telemetryDrainExtensions } from '../platform/telemetry-drain/index.js';
16
19
  export { testRunExtensions, TestRunNotReadyError, waitForState, } from '../platform/test-run/index.js';
17
20
  export { vpnConnectionExtensions, VpnConnectionFailedError, VpnConnectionNotReadyError, waitForActive, } from '../platform/vpn-connection/index.js';