@heroku/sdk 0.5.2 → 0.5.3

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 (53) hide show
  1. package/dist/core/create-client.d.ts +9 -1
  2. package/dist/core/create-client.js +4 -2
  3. package/dist/resources/data/redis/errors.d.ts +8 -0
  4. package/dist/resources/data/redis/errors.js +15 -0
  5. package/dist/resources/data/redis/index.d.ts +9 -0
  6. package/dist/resources/data/redis/index.js +10 -0
  7. package/dist/resources/data/redis/resolve-by-app.d.ts +35 -0
  8. package/dist/resources/data/redis/resolve-by-app.js +56 -0
  9. package/dist/resources/data/redis/wait-for-ready.d.ts +32 -0
  10. package/dist/resources/data/redis/wait-for-ready.js +46 -0
  11. package/dist/resources/extensions/data.d.ts +1 -0
  12. package/dist/resources/extensions/data.js +1 -0
  13. package/dist/resources/extensions/platform.d.ts +5 -1
  14. package/dist/resources/extensions/platform.js +5 -1
  15. package/dist/resources/platform/add-on/destroy-and-wait.d.ts +22 -0
  16. package/dist/resources/platform/add-on/destroy-and-wait.js +62 -0
  17. package/dist/resources/platform/add-on/index.d.ts +9 -5
  18. package/dist/resources/platform/add-on/index.js +6 -0
  19. package/dist/resources/platform/add-on/types.d.ts +35 -0
  20. package/dist/resources/platform/add-on/wait-for-provisioning.d.ts +19 -0
  21. package/dist/resources/platform/add-on/wait-for-provisioning.js +37 -0
  22. package/dist/resources/platform/app/index.d.ts +3 -0
  23. package/dist/resources/platform/app/index.js +3 -0
  24. package/dist/resources/platform/app/wait-for-acm-certificates.d.ts +34 -0
  25. package/dist/resources/platform/app/wait-for-acm-certificates.js +51 -0
  26. package/dist/resources/platform/container/ensure-container-stack.d.ts +18 -0
  27. package/dist/resources/platform/container/ensure-container-stack.js +32 -0
  28. package/dist/resources/platform/container/index.d.ts +13 -0
  29. package/dist/resources/platform/container/index.js +12 -0
  30. package/dist/resources/platform/container/release-images.d.ts +21 -0
  31. package/dist/resources/platform/container/release-images.js +23 -0
  32. package/dist/resources/platform/container/remove-process-types.d.ts +22 -0
  33. package/dist/resources/platform/container/remove-process-types.js +20 -0
  34. package/dist/resources/platform/dyno/index.d.ts +3 -0
  35. package/dist/resources/platform/dyno/index.js +3 -0
  36. package/dist/resources/platform/dyno/run.d.ts +76 -0
  37. package/dist/resources/platform/dyno/run.js +74 -0
  38. package/dist/resources/platform/log-session/index.js +71 -12
  39. package/dist/resources/platform/sni-endpoint/create-and-associate.d.ts +38 -0
  40. package/dist/resources/platform/sni-endpoint/create-and-associate.js +86 -0
  41. package/dist/resources/platform/sni-endpoint/index.d.ts +5 -0
  42. package/dist/resources/platform/sni-endpoint/index.js +6 -0
  43. package/dist/resources/platform/telemetry-drain/index.d.ts +6 -0
  44. package/dist/resources/platform/telemetry-drain/index.js +6 -0
  45. package/dist/resources/platform/telemetry-drain/remove-drains-for-target.d.ts +13 -0
  46. package/dist/resources/platform/telemetry-drain/remove-drains-for-target.js +25 -0
  47. package/dist/resources/platform/telemetry-drain/types.d.ts +13 -0
  48. package/dist/resources/platform/telemetry-drain/types.js +1 -0
  49. package/dist/resources/platform/test-run/index.d.ts +5 -0
  50. package/dist/resources/platform/test-run/index.js +9 -0
  51. package/dist/resources/platform/test-run/wait-for-state.d.ts +68 -0
  52. package/dist/resources/platform/test-run/wait-for-state.js +65 -0
  53. package/package.json +10 -2
@@ -7,7 +7,15 @@ 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' | 'signal' | 'timeout'> & {
11
+ /**
12
+ * Request body applied to write calls on the derived client. Primarily
13
+ * for methods whose route metadata lacks `hasRequestBody` (e.g. DELETE
14
+ * with a `force` payload) where the body cannot be passed positionally.
15
+ * The underlying client forwards this as the JSON payload.
16
+ */
17
+ body?: unknown;
18
+ };
11
19
  /**
12
20
  * Methods available on every routes-generated client in addition to
13
21
  * the resources/methods generated from the routes registry.
@@ -11,12 +11,14 @@ function mergeStickyOptions(base, next) {
11
11
  if (next.headers || base?.headers) {
12
12
  merged.headers = { ...base?.headers, ...next.headers };
13
13
  }
14
- // signal and timeout are not additive — last wins, but only if the
15
- // caller actually set them. Otherwise the previous value persists.
14
+ // signal, timeout, and body are not additive — last wins, but only if
15
+ // the caller actually set them. Otherwise the previous value persists.
16
16
  if (next.signal !== undefined)
17
17
  merged.signal = next.signal;
18
18
  if (next.timeout !== undefined)
19
19
  merged.timeout = next.timeout;
20
+ if (next.body !== undefined)
21
+ merged.body = next.body;
20
22
  return merged;
21
23
  }
22
24
  function buildClientProxy(httpClient, routes, inheritedOptions) {
@@ -0,0 +1,8 @@
1
+ import type { AddOn } from '@heroku/types/3.sdk';
2
+ import { AddonAmbiguousError, AddonNotFoundError } from '../../platform/add-on/index.js';
3
+ export declare class RedisAddonNotFoundError extends AddonNotFoundError {
4
+ constructor();
5
+ }
6
+ export declare class RedisAddonAmbiguousError extends AddonAmbiguousError {
7
+ constructor(matches: AddOn[]);
8
+ }
@@ -0,0 +1,15 @@
1
+ import { AddonAmbiguousError, AddonNotFoundError } from '../../platform/add-on/index.js';
2
+ export class RedisAddonNotFoundError extends AddonNotFoundError {
3
+ constructor() {
4
+ super('redis instance');
5
+ this.message = 'No Redis instances found.';
6
+ this.name = 'RedisAddonNotFoundError';
7
+ }
8
+ }
9
+ export class RedisAddonAmbiguousError extends AddonAmbiguousError {
10
+ constructor(matches) {
11
+ super(matches);
12
+ this.message = `Please specify a single instance. Found: ${matches.map(m => m.name).join(', ')}`;
13
+ this.name = 'RedisAddonAmbiguousError';
14
+ }
15
+ }
@@ -0,0 +1,9 @@
1
+ import type { ResolveRedisByAppOptions } from './resolve-by-app.js';
2
+ import type { WaitForRedisReadyOptions } from './wait-for-ready.js';
3
+ export { RedisAddonAmbiguousError, RedisAddonNotFoundError } from './errors.js';
4
+ export { resolveRedisByApp, type ResolveRedisByAppOptions } from './resolve-by-app.js';
5
+ export { waitForRedisReady, type WaitForRedisReadyOptions } from './wait-for-ready.js';
6
+ export declare const redisExtensions: import("../../../core/extend-resource.js").ResourceExtension<"data", "redis", {
7
+ resolveByApp: (appIdentity: string, options?: ResolveRedisByAppOptions) => Promise<import("../../platform/add-on/types.js").ResolvedAddOn>;
8
+ waitForReady: (nameOrId: string, options?: WaitForRedisReadyOptions) => Promise<import("@heroku/types/data").RedisWaitResult>;
9
+ }>;
@@ -0,0 +1,10 @@
1
+ import { extendResource } from '../../../core/extend-resource.js';
2
+ import { resolveRedisByApp } from './resolve-by-app.js';
3
+ import { waitForRedisReady } from './wait-for-ready.js';
4
+ export { RedisAddonAmbiguousError, RedisAddonNotFoundError } from './errors.js';
5
+ export { resolveRedisByApp } from './resolve-by-app.js';
6
+ export { waitForRedisReady } from './wait-for-ready.js';
7
+ export const redisExtensions = extendResource('data', 'redis', ctx => ({
8
+ resolveByApp: (appIdentity, options) => resolveRedisByApp(ctx, appIdentity, options),
9
+ waitForReady: (nameOrId, options) => waitForRedisReady(ctx, nameOrId, options),
10
+ }));
@@ -0,0 +1,35 @@
1
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
2
+ import type { ResolvedAddOn } from '../../platform/add-on/index.js';
3
+ export type ResolveRedisByAppOptions = {
4
+ /**
5
+ * Override the add-on service prefix used to identify a redis add-on
6
+ * on the app. Defaults to `heroku-redis`. The match is a prefix
7
+ * (`startsWith`) on `addon_service.name`, matching the CLI's existing
8
+ * `makeAddonsFilter` semantics.
9
+ */
10
+ addonServiceName?: string;
11
+ /**
12
+ * Optional filter narrowing the app's redis add-ons to a single
13
+ * database. Matched case-insensitively against the add-on's `name`
14
+ * and each of its `config_vars`, using substring containment. This
15
+ * mirrors the CLI's `makeAddonsFilter` behavior so migrating callers
16
+ * observe identical resolution semantics.
17
+ */
18
+ database?: string;
19
+ signal?: AbortSignal;
20
+ };
21
+ /**
22
+ * Resolve a redis add-on attached to `appIdentity`.
23
+ *
24
+ * Lists the app's add-ons, filters them to those whose
25
+ * `addon_service.name` starts with `heroku-redis` (or the caller's
26
+ * `addonServiceName`), and further narrows by the optional `database`
27
+ * filter using the same case-insensitive substring match the CLI has
28
+ * always used.
29
+ *
30
+ * Throws `RedisAddonNotFoundError` if no add-ons remain after filtering,
31
+ * `RedisAddonAmbiguousError` if more than one remains. Both subclass the
32
+ * generic add-on errors so callers doing `instanceof AddonNotFoundError`
33
+ * still match.
34
+ */
35
+ export declare function resolveRedisByApp(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options?: ResolveRedisByAppOptions): Promise<ResolvedAddOn>;
@@ -0,0 +1,56 @@
1
+ import createDebug from 'debug';
2
+ import { RedisAddonAmbiguousError, RedisAddonNotFoundError } from './errors.js';
3
+ const debug = createDebug('heroku:sdk:resources:redis');
4
+ const DEFAULT_REDIS_ADDON_SERVICE_PREFIX = 'heroku-redis';
5
+ /**
6
+ * Resolve a redis add-on attached to `appIdentity`.
7
+ *
8
+ * Lists the app's add-ons, filters them to those whose
9
+ * `addon_service.name` starts with `heroku-redis` (or the caller's
10
+ * `addonServiceName`), and further narrows by the optional `database`
11
+ * filter using the same case-insensitive substring match the CLI has
12
+ * always used.
13
+ *
14
+ * Throws `RedisAddonNotFoundError` if no add-ons remain after filtering,
15
+ * `RedisAddonAmbiguousError` if more than one remains. Both subclass the
16
+ * generic add-on errors so callers doing `instanceof AddonNotFoundError`
17
+ * still match.
18
+ */
19
+ export async function resolveRedisByApp(ctx, appIdentity, options = {}) {
20
+ options.signal?.throwIfAborted();
21
+ const { addonServiceName = DEFAULT_REDIS_ADDON_SERVICE_PREFIX, database } = options;
22
+ const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
23
+ debug('resolveByApp app=%s service-prefix=%s database=%s', appIdentity, addonServiceName, database ?? '<any>');
24
+ const addons = await platform.addOn.listByApp(appIdentity);
25
+ const filter = database?.toUpperCase();
26
+ const matches = addons.filter(addon => {
27
+ const service = addon.addon_service?.name;
28
+ if (!service || !service.startsWith(addonServiceName))
29
+ return false;
30
+ if (!filter)
31
+ return true;
32
+ return matchesDatabaseFilter(addon, filter);
33
+ });
34
+ debug('resolveByApp app=%s candidates=%d matches=%d', appIdentity, addons.length, matches.length);
35
+ if (matches.length === 0) {
36
+ throw new RedisAddonNotFoundError();
37
+ }
38
+ if (matches.length > 1) {
39
+ throw new RedisAddonAmbiguousError(matches);
40
+ }
41
+ const match = matches[0];
42
+ if (!match.id || !match.app?.id) {
43
+ throw new Error(`Resolved redis add-on is missing required fields (id=${match.id}, app.id=${match.app?.id})`);
44
+ }
45
+ return match;
46
+ }
47
+ function matchesDatabaseFilter(addon, filter) {
48
+ const configVars = addon.config_vars ?? [];
49
+ for (const configVar of configVars) {
50
+ if (configVar.toUpperCase().includes(filter))
51
+ return true;
52
+ }
53
+ if (addon.name && addon.name.toUpperCase().includes(filter))
54
+ return true;
55
+ return false;
56
+ }
@@ -0,0 +1,32 @@
1
+ import type { RedisWaitResult } from '@heroku/types/data';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ export type WaitForRedisReadyOptions = {
4
+ /**
5
+ * Polling interval in milliseconds. Defaults to 5000 (5s), matching
6
+ * the CLI's historical `--wait-interval` default.
7
+ */
8
+ intervalMs?: number;
9
+ /**
10
+ * Aborts the in-flight request and any pending poll delay.
11
+ */
12
+ signal?: AbortSignal;
13
+ /**
14
+ * Maximum total time to wait before throwing. If omitted, polls until
15
+ * the database reports ready or `signal` aborts.
16
+ */
17
+ timeoutMs?: number;
18
+ };
19
+ /**
20
+ * Poll `data.redis.wait` until the redis database reports ready
21
+ * (`waiting?: false`).
22
+ *
23
+ * A 404 from the wait endpoint during the initial provisioning window
24
+ * is treated as "still waiting" — the platform briefly returns 404 for
25
+ * a database whose row has not yet been created after the addon
26
+ * provision call resolves.
27
+ *
28
+ * Cancellation: the caller's `signal` is threaded into both the
29
+ * in-flight request (via `withOptions`) and the poll-delay `wait()`,
30
+ * so aborting takes effect during either phase.
31
+ */
32
+ export declare function waitForRedisReady(ctx: Pick<ResourceCtx, 'data'>, nameOrId: string, options?: WaitForRedisReadyOptions): Promise<RedisWaitResult>;
@@ -0,0 +1,46 @@
1
+ import { NotFoundError } from '@heroku/heroku-fetch';
2
+ import createDebug from 'debug';
3
+ import { wait } from '../../../utils/wait.js';
4
+ const debug = createDebug('heroku:sdk:resources:redis');
5
+ const DEFAULT_INTERVAL_MS = 5000;
6
+ /**
7
+ * Poll `data.redis.wait` until the redis database reports ready
8
+ * (`waiting?: false`).
9
+ *
10
+ * A 404 from the wait endpoint during the initial provisioning window
11
+ * is treated as "still waiting" — the platform briefly returns 404 for
12
+ * a database whose row has not yet been created after the addon
13
+ * provision call resolves.
14
+ *
15
+ * Cancellation: the caller's `signal` is threaded into both the
16
+ * in-flight request (via `withOptions`) and the poll-delay `wait()`,
17
+ * so aborting takes effect during either phase.
18
+ */
19
+ export async function waitForRedisReady(ctx, nameOrId, options = {}) {
20
+ const { intervalMs = DEFAULT_INTERVAL_MS, signal, timeoutMs } = options;
21
+ signal?.throwIfAborted();
22
+ const data = signal ? ctx.data.withOptions({ signal }) : ctx.data;
23
+ const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
24
+ debug('waitForReady name=%s intervalMs=%d timeoutMs=%s', nameOrId, intervalMs, timeoutMs ?? '<none>');
25
+ while (true) {
26
+ signal?.throwIfAborted();
27
+ let status;
28
+ try {
29
+ status = await data.redis.wait(nameOrId);
30
+ }
31
+ catch (error) {
32
+ if (!(error instanceof NotFoundError))
33
+ throw error;
34
+ debug('waitForReady name=%s 404 during provisioning window', nameOrId);
35
+ status = { message: 'not found', 'waiting?': true };
36
+ }
37
+ if (!status['waiting?']) {
38
+ debug('waitForReady name=%s ready', nameOrId);
39
+ return status;
40
+ }
41
+ if (deadline !== undefined && Date.now() >= deadline) {
42
+ throw new Error(`Redis database '${nameOrId}' did not become ready within ${timeoutMs}ms`);
43
+ }
44
+ await wait(intervalMs, signal);
45
+ }
46
+ }
@@ -1,4 +1,5 @@
1
1
  export { databaseExtensions } from '../data/database.js';
2
2
  export { maintenanceExtensions } from '../data/maintenance.js';
3
3
  export { postgresDatabaseExtensions } from '../data/postgres-database.js';
4
+ export { redisExtensions } from '../data/redis/index.js';
4
5
  export { transferExtensions } from '../data/transfer.js';
@@ -1,4 +1,5 @@
1
1
  export { databaseExtensions } from '../data/database.js';
2
2
  export { maintenanceExtensions } from '../data/maintenance.js';
3
3
  export { postgresDatabaseExtensions } from '../data/postgres-database.js';
4
+ export { redisExtensions } from '../data/redis/index.js';
4
5
  export { transferExtensions } from '../data/transfer.js';
@@ -1,9 +1,13 @@
1
1
  export { addOnExtensions } from '../platform/add-on/index.js';
2
2
  export { appExtensions, type AppInfo, type PipelineCouplingDetail } from '../platform/app/index.js';
3
+ export { containerExtensions, type DockerReleasesBatchUpdateOpts, NotAContainerAppError, type ReleaseDockerImagesResult, type RemoveProcessTypesOpts, } from '../platform/container/index.js';
3
4
  export { type CreateAndWaitOptions, domainExtensions, type DomainOptions, type WaitForReadyOptions, } from '../platform/domain/index.js';
4
- export { dynoExtensions, DynoNotReadyError, type DynoState, waitForInfo, type WaitForInfoOptions, } from '../platform/dyno/index.js';
5
+ export { dynoExtensions, DynoNotReadyError, type DynoState, runDyno, type RunDynoOptions, waitForInfo, type WaitForInfoOptions, } from '../platform/dyno/index.js';
5
6
  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';
6
7
  export { pipelineCouplingExtensions } from '../platform/pipeline/coupling.js';
7
8
  export { pipelineExtensions } from '../platform/pipeline/index.js';
8
9
  export { pipelinePromotionExtensions } from '../platform/pipeline/promotion.js';
9
10
  export { privateToShield, shieldToPrivate } from '../platform/shield.js';
11
+ export { createAndAssociate, type CreateAndAssociateOptions, sniEndpointExtensions } from '../platform/sni-endpoint/index.js';
12
+ export { telemetryDrainExtensions, type TelemetryDrainOptions, type TelemetryDrainTarget } from '../platform/telemetry-drain/index.js';
13
+ export { testRunExtensions, TestRunNotReadyError, type TestRunStatus, waitForState, type WaitForStateOptions, } from '../platform/test-run/index.js';
@@ -1,9 +1,13 @@
1
1
  export { addOnExtensions } from '../platform/add-on/index.js';
2
2
  export { appExtensions } from '../platform/app/index.js';
3
+ export { containerExtensions, NotAContainerAppError, } from '../platform/container/index.js';
3
4
  export { domainExtensions, } from '../platform/domain/index.js';
4
- export { dynoExtensions, DynoNotReadyError, waitForInfo, } from '../platform/dyno/index.js';
5
+ export { dynoExtensions, DynoNotReadyError, runDyno, waitForInfo, } from '../platform/dyno/index.js';
5
6
  export { logSessionExtensions, parseHerokuLogLine, } from '../platform/log-session/index.js';
6
7
  export { pipelineCouplingExtensions } from '../platform/pipeline/coupling.js';
7
8
  export { pipelineExtensions } from '../platform/pipeline/index.js';
8
9
  export { pipelinePromotionExtensions } from '../platform/pipeline/promotion.js';
9
10
  export { privateToShield, shieldToPrivate } from '../platform/shield.js';
11
+ export { createAndAssociate, sniEndpointExtensions } from '../platform/sni-endpoint/index.js';
12
+ export { telemetryDrainExtensions } from '../platform/telemetry-drain/index.js';
13
+ export { testRunExtensions, TestRunNotReadyError, waitForState, } from '../platform/test-run/index.js';
@@ -0,0 +1,22 @@
1
+ import type { AddOn } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ import type { DestroyAndWaitOptions } from './types.js';
4
+ /**
5
+ * Delete an add-on and optionally wait for deprovisioning to complete.
6
+ *
7
+ * Wraps `addOn.delete` with two pieces of orchestration:
8
+ *
9
+ * - When `wait: true` is set, polls `addOn.infoByApp` on a
10
+ * `waitIntervalMs` cadence until the add-on's `state` is no longer
11
+ * `deprovisioning`. A 404 response during polling is treated as
12
+ * successful deprovisioning (the platform deletes the record).
13
+ * - `options.onDeprovisioning` fires once after the delete response
14
+ * when polling is about to begin, letting callers surface a
15
+ * two-phase status display.
16
+ *
17
+ * When `options.force` is set, the delete request sends `{force: true}`
18
+ * as its body (scoped to the delete call only, not the poll requests).
19
+ *
20
+ * Both the delete and the poll requests send `Accept-Expansion: plan`.
21
+ */
22
+ export declare function destroyAndWait(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, addonIdentity: string, options?: DestroyAndWaitOptions): Promise<AddOn>;
@@ -0,0 +1,62 @@
1
+ import { wait } from '../../../utils/wait.js';
2
+ import { AddonProvisioningFailedError } from './errors.js';
3
+ const DEFAULT_DESTROY_WAIT_INTERVAL_MS = 5000;
4
+ /**
5
+ * Delete an add-on and optionally wait for deprovisioning to complete.
6
+ *
7
+ * Wraps `addOn.delete` with two pieces of orchestration:
8
+ *
9
+ * - When `wait: true` is set, polls `addOn.infoByApp` on a
10
+ * `waitIntervalMs` cadence until the add-on's `state` is no longer
11
+ * `deprovisioning`. A 404 response during polling is treated as
12
+ * successful deprovisioning (the platform deletes the record).
13
+ * - `options.onDeprovisioning` fires once after the delete response
14
+ * when polling is about to begin, letting callers surface a
15
+ * two-phase status display.
16
+ *
17
+ * When `options.force` is set, the delete request sends `{force: true}`
18
+ * as its body (scoped to the delete call only, not the poll requests).
19
+ *
20
+ * Both the delete and the poll requests send `Accept-Expansion: plan`.
21
+ */
22
+ export async function destroyAndWait(ctx, appIdentity, addonIdentity, options = {}) {
23
+ options.signal?.throwIfAborted();
24
+ const platform = ctx.platform
25
+ .withHeaders({ 'Accept-Expansion': 'plan' })
26
+ .withOptions({ signal: options.signal });
27
+ // The force body is scoped to the delete call only — a sticky body on the
28
+ // shared client would leak onto the GET poll requests below.
29
+ const deleteClient = options.force
30
+ ? platform.withOptions({ body: { force: true } })
31
+ : platform;
32
+ const deleted = await deleteClient.addOn.delete(appIdentity, addonIdentity);
33
+ let addon = deleted ?? {};
34
+ if (!options.wait || (addon.state !== 'deprovisioning' && addon.state !== 'provisioning')) {
35
+ return addon;
36
+ }
37
+ await options.onDeprovisioning?.(addon);
38
+ const intervalMs = options.waitIntervalMs ?? DEFAULT_DESTROY_WAIT_INTERVAL_MS;
39
+ /* eslint-disable no-await-in-loop */
40
+ while (addon.state === 'deprovisioning' || addon.state === 'provisioning') {
41
+ options.signal?.throwIfAborted();
42
+ await wait(intervalMs, options.signal);
43
+ addon = await platform.addOn.infoByApp(appIdentity, addonIdentity).catch((error) => {
44
+ // Platform deletes the record on successful deprovisioning — a 404 is success.
45
+ if (isNotFound(error)) {
46
+ return { ...addon, state: 'deprovisioned' };
47
+ }
48
+ throw error;
49
+ });
50
+ }
51
+ /* eslint-enable no-await-in-loop */
52
+ if (addon.state !== 'deprovisioned') {
53
+ throw new AddonProvisioningFailedError(addon);
54
+ }
55
+ return addon;
56
+ }
57
+ function isNotFound(error) {
58
+ return (typeof error === 'object'
59
+ && error !== null
60
+ && 'statusCode' in error
61
+ && error.statusCode === 404);
62
+ }
@@ -1,23 +1,27 @@
1
- import type { AddOnCreateOpts } from '@heroku/types/3.sdk';
1
+ import type { AddOn, AddOnCreateOpts } from '@heroku/types/3.sdk';
2
2
  import type { ListPlansForAddonOptions } from './list-plans.js';
3
- import type { AddOnOptions, CreateAndWaitOptions, ResolveAddonOptions, UpgradeAddOnOptions } from './types.js';
3
+ import type { AddOnOptions, CreateAndWaitOptions, DestroyAndWaitOptions, ResolveAddonOptions, UpgradeAddOnOptions, WaitForProvisioningOptions } from './types.js';
4
4
  import { formatPlanPriceLabel, priceForPlan } from './pricing.js';
5
5
  export { createAndWait } from './create-and-wait.js';
6
6
  export { describeAddon } from './describe.js';
7
+ export { destroyAndWait } from './destroy-and-wait.js';
7
8
  export { AddonAmbiguousError, AddonConfirmationRequiredError, AddonNotFoundError, AddonProvisioningFailedError, } from './errors.js';
8
9
  export { listPlans, listPlansForAddon, type ListPlansForAddonOptions } from './list-plans.js';
9
10
  export { formatPlanPriceLabel, type FormatPlanPriceLabelOptions, type PlanPriceBreakdown, priceForPlan, } from './pricing.js';
10
11
  export { resolveAddon, resolveAddonByAttachment } from './resolve.js';
11
- export type { AddOnOptions, CreateAndWaitOptions, DescribedAddOn, ResolveAddonOptions, ResolvedAddOn, UpgradeAddOnOptions, } from './types.js';
12
+ export type { AddOnOptions, CreateAndWaitOptions, DescribedAddOn, DestroyAndWaitOptions, ResolveAddonOptions, ResolvedAddOn, UpgradeAddOnOptions, WaitForProvisioningOptions, } from './types.js';
12
13
  export { upgrade } from './upgrade.js';
14
+ export { waitForProvisioning } from './wait-for-provisioning.js';
13
15
  export declare const addOnExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "addOn", {
14
- createAndWait: (appIdentity: string, body: AddOnCreateOpts, options?: CreateAndWaitOptions) => Promise<import("@heroku/types/3.sdk").AddOn>;
16
+ createAndWait: (appIdentity: string, body: AddOnCreateOpts, options?: CreateAndWaitOptions) => Promise<AddOn>;
15
17
  describe: (addonIdentity: string, options?: ResolveAddonOptions) => Promise<import("./types.js").DescribedAddOn>;
18
+ destroyAndWait: (appIdentity: string, addonIdentity: string, options?: DestroyAndWaitOptions) => Promise<AddOn>;
16
19
  formatPlanPriceLabel: typeof formatPlanPriceLabel;
17
20
  listPlans: (serviceIdentity: string, options?: AddOnOptions) => Promise<import("@heroku/types/3.sdk").Plan[]>;
18
21
  listPlansForAddon: (addonIdentity: string, options?: ListPlansForAddonOptions) => Promise<import("@heroku/types/3.sdk").Plan[]>;
19
22
  priceForPlan: typeof priceForPlan;
20
23
  resolve: (addonIdentity: string, options?: ResolveAddonOptions) => Promise<import("./types.js").ResolvedAddOn>;
21
24
  resolveByAttachment: (appIdentity: string, attachmentName: string, options?: AddOnOptions) => Promise<import("./types.js").ResolvedAddOn>;
22
- upgrade: (addonIdentity: string, plan: string, options?: UpgradeAddOnOptions) => Promise<import("@heroku/types/3.sdk").AddOn>;
25
+ upgrade: (addonIdentity: string, plan: string, options?: UpgradeAddOnOptions) => Promise<AddOn>;
26
+ waitForProvisioning: (addon: AddOn, options?: WaitForProvisioningOptions) => Promise<AddOn>;
23
27
  }>;
@@ -1,20 +1,25 @@
1
1
  import { extendResource } from '../../../core/extend-resource.js';
2
2
  import { createAndWait } from './create-and-wait.js';
3
3
  import { describeAddon } from './describe.js';
4
+ import { destroyAndWait } from './destroy-and-wait.js';
4
5
  import { listPlans, listPlansForAddon } from './list-plans.js';
5
6
  import { formatPlanPriceLabel, priceForPlan } from './pricing.js';
6
7
  import { resolveAddon, resolveAddonByAttachment } from './resolve.js';
7
8
  import { upgrade } from './upgrade.js';
9
+ import { waitForProvisioning } from './wait-for-provisioning.js';
8
10
  export { createAndWait } from './create-and-wait.js';
9
11
  export { describeAddon } from './describe.js';
12
+ export { destroyAndWait } from './destroy-and-wait.js';
10
13
  export { AddonAmbiguousError, AddonConfirmationRequiredError, AddonNotFoundError, AddonProvisioningFailedError, } from './errors.js';
11
14
  export { listPlans, listPlansForAddon } from './list-plans.js';
12
15
  export { formatPlanPriceLabel, priceForPlan, } from './pricing.js';
13
16
  export { resolveAddon, resolveAddonByAttachment } from './resolve.js';
14
17
  export { upgrade } from './upgrade.js';
18
+ export { waitForProvisioning } from './wait-for-provisioning.js';
15
19
  export const addOnExtensions = extendResource('platform', 'addOn', ctx => ({
16
20
  createAndWait: (appIdentity, body, options) => createAndWait(ctx, appIdentity, body, options),
17
21
  describe: (addonIdentity, options) => describeAddon(ctx, addonIdentity, options),
22
+ destroyAndWait: (appIdentity, addonIdentity, options) => destroyAndWait(ctx, appIdentity, addonIdentity, options),
18
23
  formatPlanPriceLabel,
19
24
  listPlans: (serviceIdentity, options) => listPlans(ctx, serviceIdentity, options),
20
25
  listPlansForAddon: (addonIdentity, options) => listPlansForAddon(ctx, addonIdentity, options),
@@ -22,4 +27,5 @@ export const addOnExtensions = extendResource('platform', 'addOn', ctx => ({
22
27
  resolve: (addonIdentity, options) => resolveAddon(ctx, addonIdentity, options),
23
28
  resolveByAttachment: (appIdentity, attachmentName, options) => resolveAddonByAttachment(ctx, appIdentity, attachmentName, options),
24
29
  upgrade: (addonIdentity, plan, options) => upgrade(ctx, addonIdentity, plan, options),
30
+ waitForProvisioning: (addon, options) => waitForProvisioning(ctx, addon, options),
25
31
  }));
@@ -24,6 +24,41 @@ export type DescribedAddOn = ResolvedAddOn & {
24
24
  export type UpgradeAddOnOptions = ResolveAddonOptions & {
25
25
  onResolved?: (addon: ResolvedAddOn) => Promise<void> | void;
26
26
  };
27
+ export type DestroyAndWaitOptions = {
28
+ /**
29
+ * When true, sends `{force: true}` as the delete body — allows
30
+ * destruction even if the add-on is attached to other apps. Mirrors
31
+ * the CLI's `--force` flag. Defaults to false.
32
+ */
33
+ force?: boolean;
34
+ /**
35
+ * Fires once after the initial delete returns and the add-on is still
36
+ * deprovisioning, before the first poll. Useful for two-phase UX:
37
+ * e.g. "Destroying…" followed by "Waiting for deprovisioning…".
38
+ *
39
+ * Only invoked when `wait: true` and the delete response state is
40
+ * `deprovisioning`.
41
+ */
42
+ onDeprovisioning?: (addon: AddOn) => Promise<void> | void;
43
+ /**
44
+ * If true, poll until the add-on leaves `deprovisioning`. If the
45
+ * terminal state is not `deprovisioned`, throws `AddonProvisioningFailedError`.
46
+ *
47
+ * If false (the default), returns immediately after the delete call.
48
+ */
49
+ wait?: boolean;
50
+ /** Polling interval in milliseconds. Defaults to 5000. */
51
+ waitIntervalMs?: number;
52
+ } & AddOnOptions;
53
+ export type WaitForProvisioningOptions = AddOnOptions & {
54
+ /**
55
+ * When provided, polls `GET /apps/:app/addons/:addon` (app-scoped).
56
+ * Without it, polls `GET /addons/:addon` (global).
57
+ */
58
+ appIdentity?: string;
59
+ /** Polling interval in milliseconds. Defaults to 5000. */
60
+ waitIntervalMs?: number;
61
+ };
27
62
  export type CreateAndWaitOptions = {
28
63
  /**
29
64
  * Fires once after the initial create returns and the add-on is
@@ -0,0 +1,19 @@
1
+ import type { AddOn } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ import type { WaitForProvisioningOptions } from './types.js';
4
+ /**
5
+ * Poll add-on state until it leaves a non-terminal state.
6
+ *
7
+ * When `appIdentity` is provided, polls `GET /apps/:app/addons/:addon`
8
+ * (app-scoped). Without it, polls `GET /addons/:addon` (global).
9
+ *
10
+ * Terminal states: `provisioned`, `deprovisioned`. Polling continues
11
+ * while the add-on is in `provisioning` or `deprovisioning`.
12
+ *
13
+ * Throws `AddonProvisioningFailedError` if the terminal state is
14
+ * `deprovisioned`.
15
+ *
16
+ * The `Accept-Expansion: addon_service,plan` header is included on
17
+ * each poll to match the existing CLI behaviour.
18
+ */
19
+ export declare function waitForProvisioning(ctx: Pick<ResourceCtx, 'platform'>, addon: AddOn, options?: WaitForProvisioningOptions): Promise<AddOn>;
@@ -0,0 +1,37 @@
1
+ import { wait } from '../../../utils/wait.js';
2
+ import { AddonProvisioningFailedError } from './errors.js';
3
+ const DEFAULT_WAIT_INTERVAL_MS = 5000;
4
+ /**
5
+ * Poll add-on state until it leaves a non-terminal state.
6
+ *
7
+ * When `appIdentity` is provided, polls `GET /apps/:app/addons/:addon`
8
+ * (app-scoped). Without it, polls `GET /addons/:addon` (global).
9
+ *
10
+ * Terminal states: `provisioned`, `deprovisioned`. Polling continues
11
+ * while the add-on is in `provisioning` or `deprovisioning`.
12
+ *
13
+ * Throws `AddonProvisioningFailedError` if the terminal state is
14
+ * `deprovisioned`.
15
+ *
16
+ * The `Accept-Expansion: addon_service,plan` header is included on
17
+ * each poll to match the existing CLI behaviour.
18
+ */
19
+ export async function waitForProvisioning(ctx, addon, options = {}) {
20
+ options.signal?.throwIfAborted();
21
+ const intervalMs = options.waitIntervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
22
+ const platform = ctx.platform.withHeaders({ 'Accept-Expansion': 'addon_service,plan' });
23
+ let current = addon;
24
+ /* eslint-disable no-await-in-loop */
25
+ while (current.state === 'provisioning' || current.state === 'deprovisioning') {
26
+ options.signal?.throwIfAborted();
27
+ await wait(intervalMs, options.signal);
28
+ current = options.appIdentity
29
+ ? await platform.addOn.infoByApp(options.appIdentity, current.name)
30
+ : await platform.addOn.info(current.name);
31
+ }
32
+ /* eslint-enable no-await-in-loop */
33
+ if (current.state === 'deprovisioned') {
34
+ throw new AddonProvisioningFailedError(current);
35
+ }
36
+ return current;
37
+ }
@@ -1,7 +1,9 @@
1
1
  import type { App } from '@heroku/types/3.sdk';
2
2
  import type { ResourceCtx } from '../../../core/extend-resource.js';
3
3
  import type { DescribeAppOptions } from './info.js';
4
+ import type { WaitForACMCertificatesOptions } from './wait-for-acm-certificates.js';
4
5
  export { type AppInfo, describeApp, type DescribeAppOptions, type PipelineCouplingDetail, } from './info.js';
6
+ export { waitForACMCertificates, type WaitForACMCertificatesOptions } from './wait-for-acm-certificates.js';
5
7
  export type AppMaintenanceOptions = {
6
8
  signal?: AbortSignal;
7
9
  };
@@ -55,4 +57,5 @@ export declare const appExtensions: import("../../../core/extend-resource.js").R
55
57
  getGeneration: (appIdentity: string, options?: GetGenerationOptions) => Promise<GenerationKind | undefined>;
56
58
  getProcessTier: (appIdentity: string, options?: GetProcessTierOptions) => Promise<ProcessTier | undefined>;
57
59
  isShielded: (appIdentity: string, options?: IsShieldedOptions) => Promise<boolean>;
60
+ waitForACMCertificates: (appIdentity: string, options?: WaitForACMCertificatesOptions) => Promise<import("@heroku/types/3.sdk").Domain[]>;
58
61
  }>;
@@ -1,6 +1,8 @@
1
1
  import { extendResource } from '../../../core/extend-resource.js';
2
2
  import { describeApp } from './info.js';
3
+ import { waitForACMCertificates } from './wait-for-acm-certificates.js';
3
4
  export { describeApp, } from './info.js';
5
+ export { waitForACMCertificates } from './wait-for-acm-certificates.js';
4
6
  export async function enableMaintenance(ctx, appIdentity, options = {}) {
5
7
  options.signal?.throwIfAborted();
6
8
  return ctx.platform.app.update(appIdentity, { maintenance: true });
@@ -50,4 +52,5 @@ export const appExtensions = extendResource('platform', 'app', ctx => ({
50
52
  getGeneration: (appIdentity, options) => getGeneration(ctx, appIdentity, options),
51
53
  getProcessTier: (appIdentity, options) => getProcessTier(ctx, appIdentity, options),
52
54
  isShielded: (appIdentity, options) => isShielded(ctx, appIdentity, options),
55
+ waitForACMCertificates: (appIdentity, options) => waitForACMCertificates(ctx, appIdentity, options),
53
56
  }));
@@ -0,0 +1,34 @@
1
+ import type { Domain } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ export type WaitForACMCertificatesOptions = {
4
+ /**
5
+ * Base polling interval in milliseconds. Defaults to 15000 (the
6
+ * CLI's 15s base). The effective delay grows with the CLI's backoff
7
+ * formula, scaled off this value.
8
+ */
9
+ intervalMs?: number;
10
+ /**
11
+ * Abort signal to cancel the operation.
12
+ */
13
+ signal?: AbortSignal;
14
+ /**
15
+ * Maximum time in milliseconds to wait before giving up.
16
+ * If undefined, waits indefinitely (preserving the CLI's
17
+ * poll-forever behavior) until the signal aborts.
18
+ */
19
+ timeoutMs?: number;
20
+ };
21
+ /**
22
+ * Waits for ACM (Automated Certificate Management) certificates to be
23
+ * issued on all custom domains of an app.
24
+ *
25
+ * Polls the app's domains until every `custom` domain reaches a
26
+ * terminal `acm_status` of `'cert issued'` or `'failed'`, then returns
27
+ * the custom domains. `heroku` domains are ignored and never block
28
+ * termination.
29
+ *
30
+ * @throws if any custom domain finishes with `acm_status: 'failed'`
31
+ * (`'ACM not enabled for some domains'`), or if `timeoutMs` elapses
32
+ * before all custom domains reach a terminal status.
33
+ */
34
+ export declare function waitForACMCertificates(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options?: WaitForACMCertificatesOptions): Promise<Domain[]>;