@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,10 +1,11 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { ensureContainerStack } from './ensure-container-stack.js';
2
3
  /**
3
4
  * Remove one or more process types from a container app
4
5
  */
5
6
  export async function removeProcessTypes(ctx, appIdentity, processTypes, options = {}) {
6
7
  await ensureContainerStack(ctx, appIdentity, options);
7
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
8
+ const platform = withSignal(ctx.platform, options.signal);
8
9
  // eslint-disable-next-line camelcase
9
10
  const requestBody = { docker_image: null };
10
11
  const updatedFormations = [];
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared shape of the platform's delinquency payloads. `AccountDelinquency`
3
+ * and `TeamDelinquency` are structurally identical — each carries the two
4
+ * scheduled timestamps (or `null` when nothing is scheduled) — so a single
5
+ * formatter serves both.
6
+ */
7
+ export interface DelinquencySchedule {
8
+ scheduled_deletion_time: null | string;
9
+ scheduled_suspension_time: null | string;
10
+ }
11
+ /** The kind of resource a delinquency applies to; used in the message text. */
12
+ export type DelinquencyResource = 'account' | 'team';
13
+ /**
14
+ * Format the delinquency warning sentence for a resource, or `undefined` when
15
+ * there is nothing to say. Pure — formatting only; the caller emits it.
16
+ *
17
+ * Produces a message for an upcoming suspension, a past suspension with an
18
+ * upcoming deletion, or an upcoming deletion. Returns `undefined` when neither
19
+ * timestamp is set, or when a suspension is already past with no deletion
20
+ * scheduled. Dates render in UTC so the message reads the same for every
21
+ * consumer. `now` defaults to the wall clock and is injectable for testing.
22
+ */
23
+ export declare function formatDelinquencyWarning(resource: DelinquencyResource, delinquency: DelinquencySchedule | null | undefined, now?: number): string | undefined;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Format the delinquency warning sentence for a resource, or `undefined` when
3
+ * there is nothing to say. Pure — formatting only; the caller emits it.
4
+ *
5
+ * Produces a message for an upcoming suspension, a past suspension with an
6
+ * upcoming deletion, or an upcoming deletion. Returns `undefined` when neither
7
+ * timestamp is set, or when a suspension is already past with no deletion
8
+ * scheduled. Dates render in UTC so the message reads the same for every
9
+ * consumer. `now` defaults to the wall clock and is injectable for testing.
10
+ */
11
+ export function formatDelinquencyWarning(resource, delinquency, now = Date.now()) {
12
+ const suspension = parseTime(delinquency?.scheduled_suspension_time);
13
+ const deletion = parseTime(delinquency?.scheduled_deletion_time);
14
+ if (!suspension && !deletion)
15
+ return undefined;
16
+ if (suspension) {
17
+ if (suspension > now) {
18
+ return `This ${resource} is delinquent with payment and we'll suspend it on ${new Date(suspension).toUTCString()}.`;
19
+ }
20
+ if (deletion) {
21
+ return `This ${resource} is delinquent with payment and we suspended it on ${new Date(suspension).toUTCString()}.`
22
+ + ` If the ${resource} is still delinquent, we'll delete it on ${new Date(deletion).toUTCString()}.`;
23
+ }
24
+ return undefined;
25
+ }
26
+ if (deletion) {
27
+ return `This ${resource} is delinquent with payment and we'll delete it on ${new Date(deletion).toUTCString()}.`;
28
+ }
29
+ return undefined;
30
+ }
31
+ /**
32
+ * Parse an ISO timestamp to epoch ms, or `undefined` when it is absent or
33
+ * unparseable. A `0`/`NaN` result is treated as absent so it never trips the
34
+ * truthiness checks above.
35
+ */
36
+ function parseTime(time) {
37
+ if (!time)
38
+ return undefined;
39
+ const ms = Date.parse(time);
40
+ return Number.isNaN(ms) || ms === 0 ? undefined : ms;
41
+ }
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  /**
2
3
  * Delete all custom domains from an app.
3
4
  *
@@ -7,7 +8,7 @@
7
8
  export async function clearCustomDomains(ctx, appIdentity, options = {}) {
8
9
  const { signal } = options;
9
10
  signal?.throwIfAborted();
10
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
11
+ const platform = withSignal(ctx.platform, signal);
11
12
  // List and filter to custom domains
12
13
  const domains = await platform.domain.list(appIdentity);
13
14
  const customDomains = domains.filter(d => d.kind === 'custom');
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { waitForReady } from './wait-for-ready.js';
2
3
  /**
3
4
  * Create a domain with SNI endpoint resolution and optional waiting.
@@ -11,7 +12,7 @@ import { waitForReady } from './wait-for-ready.js';
11
12
  export async function createAndWait(ctx, appIdentity, hostname, options = {}) {
12
13
  const { poller, resolveSniEndpoint, signal, sniEndpoint, timeoutMs, wait: shouldWait, waitIntervalMs, } = options;
13
14
  signal?.throwIfAborted();
14
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
15
+ const platform = withSignal(ctx.platform, signal);
15
16
  let resolvedSniEndpoint = null;
16
17
  if (sniEndpoint) {
17
18
  // Explicit SNI endpoint provided
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { wait } from '../../../utils/wait.js';
2
3
  const DEFAULT_WAIT_INTERVAL_MS = 5000;
3
4
  /**
@@ -11,7 +12,7 @@ const DEFAULT_WAIT_INTERVAL_MS = 5000;
11
12
  export async function waitForReady(ctx, appIdentity, options = {}) {
12
13
  const { domain, hostname, poller, signal, timeoutMs, waitIntervalMs = DEFAULT_WAIT_INTERVAL_MS, } = options;
13
14
  signal?.throwIfAborted();
14
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
15
+ const platform = withSignal(ctx.platform, signal);
15
16
  let domains;
16
17
  if (domain) {
17
18
  domains = [domain];
@@ -1,4 +1,5 @@
1
1
  import { HerokuApiClient, NotFoundError } from '@heroku/heroku-fetch';
2
+ import { withSignal } from '../../../core/with-signal.js';
2
3
  import { wait } from '../../../utils/wait.js';
3
4
  import { waitForInfo } from './wait-for-info.js';
4
5
  /** The app feature that gates Heroku Exec. */
@@ -20,7 +21,7 @@ const DEFAULT_SETTLE_MS = 2000;
20
21
  */
21
22
  export async function execPrereqs(ctx, appIdentity, options = {}) {
22
23
  options.signal?.throwIfAborted();
23
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
24
+ const platform = withSignal(ctx.platform, options.signal);
24
25
  const [app, buildpacks, configVars, feature] = await Promise.all([
25
26
  platform.app.info(appIdentity),
26
27
  platform.buildpackInstallation.list(appIdentity),
@@ -51,7 +52,7 @@ export async function execPrereqs(ctx, appIdentity, options = {}) {
51
52
  */
52
53
  export async function enableExec(ctx, appIdentity, options = {}) {
53
54
  options.signal?.throwIfAborted();
54
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
55
+ const platform = withSignal(ctx.platform, options.signal);
55
56
  await platform.appFeature.update(appIdentity, EXEC_FEATURE, { enabled: true });
56
57
  }
57
58
  /**
@@ -82,7 +83,7 @@ export async function restartForExec(ctx, appIdentity, dynoIdentity, options = {
82
83
  const { settleMs = DEFAULT_SETTLE_MS, ...waitOptions } = options;
83
84
  const { signal } = waitOptions;
84
85
  signal?.throwIfAborted();
85
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
86
+ const platform = withSignal(ctx.platform, signal);
86
87
  await wait(settleMs, signal);
87
88
  await platform.dyno.restartAll(appIdentity);
88
89
  return waitForInfo(ctx, appIdentity, dynoIdentity, {
@@ -1,4 +1,5 @@
1
1
  import { extendResource } from '../../../core/extend-resource.js';
2
+ import { withSignal } from '../../../core/with-signal.js';
2
3
  import { enableExec, exchangeExecCredentials, execPrereqs, execStatus, restartForExec, } from './exec.js';
3
4
  import { listExtended } from './list-extended.js';
4
5
  import { runDyno } from './run.js';
@@ -20,15 +21,17 @@ export async function scaleDynos(ctx, appIdentity, updates, options = {}) {
20
21
  }
21
22
  export async function restartDynos(ctx, appIdentity, target, options = {}) {
22
23
  options.signal?.throwIfAborted();
24
+ // Bind the signal so the request aborts in flight.
25
+ const platform = withSignal(ctx.platform, options.signal);
23
26
  if (!target) {
24
- await ctx.platform.dyno.restartAll(appIdentity);
27
+ await platform.dyno.restartAll(appIdentity);
25
28
  return;
26
29
  }
27
30
  if ('dyno' in target) {
28
- await ctx.platform.dyno.restart(appIdentity, target.dyno);
31
+ await platform.dyno.restart(appIdentity, target.dyno);
29
32
  return;
30
33
  }
31
- await ctx.platform.dyno.restartFormation(appIdentity, target.type);
34
+ await platform.dyno.restartFormation(appIdentity, target.type);
32
35
  }
33
36
  export const dynoExtensions = extendResource('platform', 'dyno', ctx => ({
34
37
  enableExec: (appIdentity, options) => (enableExec(ctx, appIdentity, options)),
@@ -1,4 +1,5 @@
1
1
  import { HerokuApiClient, HerokuApiError } from '@heroku/heroku-fetch';
2
+ import { withSignal } from '../../../core/with-signal.js';
2
3
  const DEFAULT_RUN_RETRIES = 2;
3
4
  const ACCEPT_RUN_INSIDE = 'application/vnd.heroku+json; version=3.sdk';
4
5
  /**
@@ -69,6 +70,6 @@ async function postRun(ctx, appIdentity, body, options) {
69
70
  });
70
71
  return (await response.json());
71
72
  }
72
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
73
+ const platform = withSignal(ctx.platform, options.signal);
73
74
  return platform.dyno.create(appIdentity, body);
74
75
  }
@@ -1,4 +1,5 @@
1
1
  import { HerokuApiError, NotFoundError, RateLimitError } from '@heroku/heroku-fetch';
2
+ import { withSignal } from '../../../core/with-signal.js';
2
3
  import { wait } from '../../../utils/wait.js';
3
4
  const DEFAULT_ATTEMPTS = 20;
4
5
  const DEFAULT_DELAY_MS = 1000;
@@ -62,7 +63,7 @@ export async function waitForInfo(ctx, appIdentity, dynoIdentity, options = {})
62
63
  signal?.throwIfAborted();
63
64
  // Scope the in-flight info call to the caller's signal so an abort
64
65
  // takes effect during the API call, not just during the backoff.
65
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
66
+ const platform = withSignal(ctx.platform, signal);
66
67
  let lastError;
67
68
  let lastDyno;
68
69
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { wait } from '../../../utils/wait.js';
2
3
  const DEFAULT_DELAY_MS = 10_000;
3
4
  const LATEST_RELEASE_RANGE = 'version ..; max=1, order=desc';
@@ -45,7 +46,7 @@ export class ReleaseNotConvergedError extends Error {
45
46
  export async function waitForRelease(ctx, appIdentity, options = {}) {
46
47
  const { attempts, delayMs = DEFAULT_DELAY_MS, onPoll, signal, type, withRun, } = options;
47
48
  signal?.throwIfAborted();
48
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
49
+ const platform = withSignal(ctx.platform, signal);
49
50
  const releases = await platform
50
51
  .withHeaders({ Range: LATEST_RELEASE_RANGE })
51
52
  .release.list(appIdentity);
@@ -1,6 +1,7 @@
1
1
  import { HerokuApiClient } from '@heroku/heroku-fetch';
2
2
  import createDebug from 'debug';
3
3
  import { extendResource } from '../../../core/extend-resource.js';
4
+ import { withSignal } from '../../../core/with-signal.js';
4
5
  import { getGeneration } from '../app/index.js';
5
6
  import { parseHerokuLogLine } from './parser.js';
6
7
  export { parseHerokuLogLine, } from './parser.js';
@@ -37,6 +38,9 @@ const TRANSPORT_RETRY_MAX_DELAY_MS = 5000;
37
38
  export async function* streamLogs(ctx, appIdentity, options = {}) {
38
39
  const { dyno, lines, maxConsecutiveTransportErrors = MAX_CONSECUTIVE_TRANSPORT_ERRORS, maxRetryDelayMs = TRANSPORT_RETRY_MAX_DELAY_MS, retryBaseDelayMs = TRANSPORT_RETRY_BASE_DELAY_MS, sessionTimeoutMs = DEFAULT_FIR_SESSION_TIMEOUT_MS, signal, source, tail = false, type, } = options;
39
40
  signal?.throwIfAborted();
41
+ // Bind the caller's signal to the platform client so the session-create
42
+ // request below aborts in flight, not merely between reads of the stream.
43
+ const platform = withSignal(ctx.platform, signal);
40
44
  const generation = await getGeneration(ctx, appIdentity, { signal });
41
45
  const isFir = generation === 'fir';
42
46
  // Fir doesn't support a bounded session; the platform always streams.
@@ -52,7 +56,7 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
52
56
  let consecutiveTransportErrors = 0;
53
57
  while (true) {
54
58
  debug('streamLogs creating session app=%s generation=%s tail=%s', appIdentity, generation ?? '<unknown>', effectiveTail);
55
- const session = await ctx.platform.logSession.create(appIdentity, createOpts);
59
+ const session = await platform.logSession.create(appIdentity, createOpts);
56
60
  if (!session.logplex_url) {
57
61
  throw new Error('Log session response did not include a logplex_url.');
58
62
  }
@@ -81,6 +85,7 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
81
85
  // header and keeps sending `text/plain`).
82
86
  const response = await logplexClient.stream(`${parsed.pathname}${parsed.search}`, {
83
87
  headers: { Accept: 'text/event-stream' },
88
+ signal,
84
89
  });
85
90
  if (!response.body) {
86
91
  throw new Error('Logplex stream returned no body.');
@@ -1,5 +1,6 @@
1
1
  import createDebug from 'debug';
2
2
  import { extendResource } from '../../../core/extend-resource.js';
3
+ import { withSignal } from '../../../core/with-signal.js';
3
4
  const debug = createDebug('heroku:sdk:resources:pipeline-coupling');
4
5
  const APPS_FILTER_LIMIT = 1000;
5
6
  /**
@@ -13,7 +14,9 @@ const APPS_FILTER_LIMIT = 1000;
13
14
  */
14
15
  export async function listPipelineApps(ctx, pipelineId, options = {}) {
15
16
  options.signal?.throwIfAborted();
16
- const allCouplings = await ctx.platform.pipelineCoupling.listByPipeline(pipelineId);
17
+ // Bind the signal so both requests abort in flight, not just at the checkpoints.
18
+ const platform = withSignal(ctx.platform, options.signal);
19
+ const allCouplings = await platform.pipelineCoupling.listByPipeline(pipelineId);
17
20
  // Drop malformed couplings (no app id) before issuing the bulk filter call.
18
21
  const couplings = allCouplings.filter(coupling => coupling.app?.id);
19
22
  const skipped = allCouplings.length - couplings.length;
@@ -35,7 +38,7 @@ export async function listPipelineApps(ctx, pipelineId, options = {}) {
35
38
  }
36
39
  options.signal?.throwIfAborted();
37
40
  const ids = couplingsToResolve.map(coupling => coupling.app.id);
38
- const apps = await ctx.platform
41
+ const apps = await platform
39
42
  .withHeaders({ Range: `id ..; max=${APPS_FILTER_LIMIT};` })
40
43
  .filterApps.apps({ in: { id: ids } });
41
44
  if (apps.length !== ids.length) {
@@ -1,5 +1,6 @@
1
1
  import { HerokuApiClient } from '@heroku/heroku-fetch';
2
2
  import { extendResource } from '../../../core/extend-resource.js';
3
+ import { withSignal } from '../../../core/with-signal.js';
3
4
  export class PipelineNotFoundError extends Error {
4
5
  identity;
5
6
  id = 'not_found';
@@ -52,7 +53,8 @@ const UUID_RE = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
52
53
  export async function resolvePipeline(ctx, identity, options = {}) {
53
54
  options.signal?.throwIfAborted();
54
55
  if (UUID_RE.test(identity)) {
55
- return ctx.platform.pipeline.info(identity);
56
+ // Bind the signal so the lookup aborts in flight.
57
+ return withSignal(ctx.platform, options.signal).pipeline.info(identity);
56
58
  }
57
59
  const apiClient = new HerokuApiClient({
58
60
  ...options.clientOptions,
@@ -60,6 +62,7 @@ export async function resolvePipeline(ctx, identity, options = {}) {
60
62
  });
61
63
  const response = await apiClient.get('/pipelines', {
62
64
  searchParams: { 'eq[name]': identity },
65
+ signal: options.signal,
63
66
  });
64
67
  const matches = (await response.json());
65
68
  if (matches.length === 0) {
@@ -1,28 +1,33 @@
1
1
  import { HerokuApiClient } from '@heroku/heroku-fetch';
2
2
  import createDebug from 'debug';
3
3
  import { extendResource } from '../../../core/extend-resource.js';
4
+ import { withSignal } from '../../../core/with-signal.js';
5
+ import { isAbortError } from '../../../utils/is-abort-error.js';
4
6
  import { wait } from '../../../utils/wait.js';
5
7
  const debug = createDebug('heroku:sdk:resources:pipeline-promotion');
6
8
  const DEFAULT_INTERVAL_MS = 1000;
7
9
  const DEFAULT_RELEASE_STREAM_MAX_ATTEMPTS = 100;
8
10
  export async function promotePipeline(ctx, body, options = {}) {
9
11
  const { intervalMs = DEFAULT_INTERVAL_MS, onReleaseStream, releaseStreamMaxAttempts = DEFAULT_RELEASE_STREAM_MAX_ATTEMPTS, signal, timeoutMs, } = options;
12
+ // Bind the signal to the client so the create/poll/release-info requests
13
+ // abort in flight, not just the delay between polls.
14
+ const platform = withSignal(ctx.platform, signal);
10
15
  debug('promote source=%s targets=%d', body.source?.app?.id ?? '<unknown>', body.targets?.length ?? 0);
11
- const promotion = await ctx.platform.pipelinePromotion.create(body);
16
+ const promotion = await platform.pipelinePromotion.create(body);
12
17
  if (!promotion.id) {
13
18
  throw new Error('Pipeline promotion response did not include an id');
14
19
  }
15
20
  debug('promote created promotion=%s intervalMs=%d timeoutMs=%s', promotion.id, intervalMs, timeoutMs ?? '<none>');
16
21
  const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
17
22
  const streamRunner = onReleaseStream
18
- ? createReleaseStreamRunner(ctx, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal)
23
+ ? createReleaseStreamRunner(platform, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal)
19
24
  : undefined;
20
25
  let poll = 0;
21
26
  while (true) {
22
27
  signal?.throwIfAborted();
23
28
  poll++;
24
29
  // eslint-disable-next-line no-await-in-loop
25
- const targets = await ctx.platform.pipelinePromotionTarget.list(promotion.id);
30
+ const targets = await platform.pipelinePromotionTarget.list(promotion.id);
26
31
  debug('promote poll=%d promotion=%s statuses=%o', poll, promotion.id, targets.map(target => target.status));
27
32
  if (targets.every(target => target.status !== 'pending')) {
28
33
  debug('promote terminal promotion=%s polls=%d', promotion.id, poll);
@@ -43,7 +48,7 @@ export async function promotePipeline(ctx, body, options = {}) {
43
48
  * output stream and hands it to `onReleaseStream`. Subsequent calls are
44
49
  * no-ops; the stream is one-shot per promotion.
45
50
  */
46
- function createReleaseStreamRunner(ctx, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal) {
51
+ function createReleaseStreamRunner(platform, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal) {
47
52
  let handled = false;
48
53
  return async (targets) => {
49
54
  if (handled || targets.length !== 1)
@@ -53,7 +58,7 @@ function createReleaseStreamRunner(ctx, onReleaseStream, releaseStreamMaxAttempt
53
58
  return;
54
59
  handled = true;
55
60
  debug('promote release-stream lookup app=%s release=%s', target.app.id, target.release.id);
56
- const release = await ctx.platform.release.info(target.app.id, target.release.id);
61
+ const release = await platform.release.info(target.app.id, target.release.id);
57
62
  if (!release.output_stream_url) {
58
63
  debug('promote release-stream skipped reason=no-output-stream-url');
59
64
  return;
@@ -84,9 +89,12 @@ async function fetchReleaseOutput(url, maxAttempts, intervalMs, signal) {
84
89
  let response;
85
90
  try {
86
91
  // eslint-disable-next-line no-await-in-loop
87
- response = await buslClient.stream(path);
92
+ response = await buslClient.stream(path, { signal });
88
93
  }
89
- catch {
94
+ catch (error) {
95
+ // An abort is terminal, not a retryable miss — let it propagate.
96
+ if (signal?.aborted || isAbortError(error))
97
+ throw error;
90
98
  // Treat HTTP errors thrown by the api-client as a retryable miss.
91
99
  response = undefined;
92
100
  }
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { debug } from './debug.js';
2
3
  /**
3
4
  * Resolves the GitHub repository name for a canonical pipeline ID.
@@ -9,7 +10,7 @@ import { debug } from './debug.js';
9
10
  export async function resolveRepoName(ctx, pipelineId, options = {}) {
10
11
  const { signal } = options;
11
12
  signal?.throwIfAborted();
12
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
13
+ const platform = withSignal(ctx.platform, signal);
13
14
  let useRepositoriesApi;
14
15
  try {
15
16
  const feature = await platform.accountFeature.info('dashboard-repositories-api');
@@ -22,7 +23,7 @@ export async function resolveRepoName(ctx, pipelineId, options = {}) {
22
23
  }
23
24
  if (useRepositoriesApi) {
24
25
  try {
25
- const repositoriesApi = signal ? ctx.repositoriesApi.withOptions({ signal }) : ctx.repositoriesApi;
26
+ const repositoriesApi = withSignal(ctx.repositoriesApi, signal);
26
27
  const repository = await repositoriesApi.githubRepository.info(pipelineId);
27
28
  signal?.throwIfAborted();
28
29
  const repositoryName = repository.full_name?.trim();
@@ -40,7 +41,7 @@ export async function resolveRepoName(ctx, pipelineId, options = {}) {
40
41
  signal?.throwIfAborted();
41
42
  debug('repositories API disabled; falling back');
42
43
  }
43
- const repositories = signal ? ctx.repositories.withOptions({ signal }) : ctx.repositories;
44
+ const repositories = withSignal(ctx.repositories, signal);
44
45
  const pipelineRepository = await repositories.pipelineRepository.info(pipelineId);
45
46
  signal?.throwIfAborted();
46
47
  const repositoryName = pipelineRepository.repository.name.trim();
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { waitForReady } from '../domain/wait-for-ready.js';
2
3
  const DEFAULT_WAIT_INTERVAL_MS = 1000;
3
4
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -35,7 +36,7 @@ const DEFAULT_TIMEOUT_MS = 30_000;
35
36
  export async function createAndAssociate(ctx, appIdentity, certificateChain, privateKey, options) {
36
37
  const { domainPoller, intervalMs = DEFAULT_WAIT_INTERVAL_MS, resolveDomains, signal, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
37
38
  signal?.throwIfAborted();
38
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
39
+ const platform = withSignal(ctx.platform, signal);
39
40
  const sniEndpoint = await platform.sniEndpoint.create(appIdentity, {
40
41
  certificate_chain: certificateChain,
41
42
  private_key: privateKey,
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { wait } from '../../../utils/wait.js';
2
3
  import { SpaceNotAllocatedError, SpaceNotReadyError } from './errors.js';
3
4
  const DEFAULT_INTERVAL_MS = 5000;
@@ -22,10 +23,7 @@ const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000;
22
23
  export async function waitForAllocated(ctx, name, options = {}) {
23
24
  const { includeNat, intervalMs = DEFAULT_INTERVAL_MS, signal, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
24
25
  signal?.throwIfAborted();
25
- let platform = ctx.platform.withHeaders({ 'Accept-Expansion': 'region' });
26
- if (signal) {
27
- platform = platform.withOptions({ signal });
28
- }
26
+ const platform = withSignal(ctx.platform.withHeaders({ 'Accept-Expansion': 'region' }), signal);
29
27
  const deadline = Date.now() + timeoutMs;
30
28
  let space = await platform.space.info(name);
31
29
  while (space.state === 'allocating') {
@@ -0,0 +1,22 @@
1
+ import type { Team, TeamDelinquency } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../core/extend-resource.js';
3
+ export type GetTeamWithDelinquencyOptions = {
4
+ signal?: AbortSignal;
5
+ };
6
+ export type TeamWithDelinquency = {
7
+ delinquency: null | TeamDelinquency;
8
+ team: Team;
9
+ };
10
+ /**
11
+ * Fetch a team together with its delinquency status.
12
+ *
13
+ * Runs the two reads concurrently. The team lookup hard-fails — there is no
14
+ * result without it. The delinquency lookup soft-fails to `null`: a hiccup on
15
+ * that secondary call must not sink an otherwise-successful team fetch. Callers
16
+ * decide whether and how to surface a warning from `delinquency` —
17
+ * `formatDelinquencyWarning` renders the sentence.
18
+ */
19
+ export declare function getTeamWithDelinquency(ctx: Pick<ResourceCtx, 'platform'>, teamIdentity: string, options?: GetTeamWithDelinquencyOptions): Promise<TeamWithDelinquency>;
20
+ export declare const teamExtensions: import("../../core/extend-resource.js").ResourceExtension<"platform", "team", {
21
+ getWithDelinquency: (teamIdentity: string, options?: GetTeamWithDelinquencyOptions) => Promise<TeamWithDelinquency>;
22
+ }>;
@@ -0,0 +1,34 @@
1
+ import createDebug from 'debug';
2
+ import { extendResource } from '../../core/extend-resource.js';
3
+ const debug = createDebug('heroku:sdk:resources:team');
4
+ /**
5
+ * Fetch a team together with its delinquency status.
6
+ *
7
+ * Runs the two reads concurrently. The team lookup hard-fails — there is no
8
+ * result without it. The delinquency lookup soft-fails to `null`: a hiccup on
9
+ * that secondary call must not sink an otherwise-successful team fetch. Callers
10
+ * decide whether and how to surface a warning from `delinquency` —
11
+ * `formatDelinquencyWarning` renders the sentence.
12
+ */
13
+ export async function getTeamWithDelinquency(ctx, teamIdentity, options = {}) {
14
+ options.signal?.throwIfAborted();
15
+ const platform = options.signal
16
+ ? ctx.platform.withOptions({ signal: options.signal })
17
+ : ctx.platform;
18
+ const [team, delinquency] = await Promise.all([
19
+ platform.team.info(teamIdentity),
20
+ platform.teamDelinquency.info(teamIdentity).catch((error) => {
21
+ // Soft-fail a secondary-call hiccup to null, but rethrow a cancellation:
22
+ // if the caller aborted, returning null would misreport a delinquent team
23
+ // as current. Anything else is logged and swallowed.
24
+ if (options.signal?.aborted)
25
+ throw error;
26
+ debug('team delinquency lookup failed; treating as not delinquent: %O', error);
27
+ return null;
28
+ }),
29
+ ]);
30
+ return { delinquency, team };
31
+ }
32
+ export const teamExtensions = extendResource('platform', 'team', ctx => ({
33
+ getWithDelinquency: (teamIdentity, options) => getTeamWithDelinquency(ctx, teamIdentity, options),
34
+ }));
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  /**
2
3
  * Remove all telemetry drains for a given app or space.
3
4
  *
@@ -11,7 +12,7 @@ export async function removeDrainsForTarget(ctx, target, options = {}) {
11
12
  const { signal } = options;
12
13
  const { app, space } = target;
13
14
  signal?.throwIfAborted();
14
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
15
+ const platform = withSignal(ctx.platform, signal);
15
16
  let drains = [];
16
17
  if (app) {
17
18
  drains = await platform.telemetryDrain.listByApp(app);
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { wait } from '../../../utils/wait.js';
2
3
  const DEFAULT_INTERVAL_MS = 1000;
3
4
  // Finite default so a stuck CI test run can't hang a caller forever.
@@ -48,7 +49,7 @@ export async function waitForState(ctx, pipelineId, testRunNumber, targetStates,
48
49
  signal?.throwIfAborted();
49
50
  // Scope the in-flight info call to the caller's signal so an abort takes
50
51
  // effect during the API call, not just during the backoff.
51
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
52
+ const platform = withSignal(ctx.platform, signal);
52
53
  const deadline = Date.now() + timeoutMs;
53
54
  while (true) {
54
55
  signal?.throwIfAborted();
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { wait } from '../../../utils/wait.js';
2
3
  const DEFAULT_INTERVAL_MS = 10 * 1000;
3
4
  const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000;
@@ -28,7 +29,7 @@ export class VpnConnectionNotReadyError extends Error {
28
29
  export async function waitForActive(ctx, spaceIdentity, vpnConnectionIdentity, options = {}) {
29
30
  const { intervalMs = DEFAULT_INTERVAL_MS, poller, signal, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
30
31
  signal?.throwIfAborted();
31
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
32
+ const platform = withSignal(ctx.platform, signal);
32
33
  const deadline = Date.now() + timeoutMs;
33
34
  let vpnConnection = await platform.vpnConnection.info(spaceIdentity, vpnConnectionIdentity);
34
35
  poller?.onStart?.(vpnConnection);
@@ -0,0 +1,11 @@
1
+ /**
2
+ * True when `error` is the standard `AbortError` raised for a cancelled
3
+ * request: `fetch`/`ky` reject an aborted request with a `DOMException` named
4
+ * `AbortError`, and `signal.throwIfAborted()` throws the same shape by default.
5
+ *
6
+ * Used so soft-failure handling (broad `.catch` defaults, retry loops) can let
7
+ * cancellation propagate instead of swallowing it or treating it as retryable.
8
+ * Pair it with `signal?.aborted` at the call site to also catch a custom-reason
9
+ * abort (`controller.abort(reason)`) or an `AbortSignal.timeout()` deadline.
10
+ */
11
+ export declare function isAbortError(error: unknown): boolean;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * True when `error` is the standard `AbortError` raised for a cancelled
3
+ * request: `fetch`/`ky` reject an aborted request with a `DOMException` named
4
+ * `AbortError`, and `signal.throwIfAborted()` throws the same shape by default.
5
+ *
6
+ * Used so soft-failure handling (broad `.catch` defaults, retry loops) can let
7
+ * cancellation propagate instead of swallowing it or treating it as retryable.
8
+ * Pair it with `signal?.aborted` at the call site to also catch a custom-reason
9
+ * abort (`controller.abort(reason)`) or an `AbortSignal.timeout()` deadline.
10
+ */
11
+ export function isAbortError(error) {
12
+ return ((error instanceof Error || error instanceof DOMException)
13
+ && error.name === 'AbortError');
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroku/sdk",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Heroku SDK",
5
5
  "author": "Heroku",
6
6
  "type": "module",