@heroku/sdk 0.11.1 → 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 (62) 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/resolve-pg-database.js +4 -7
  6. package/dist/resources/data/maintenance.js +4 -1
  7. package/dist/resources/data/postgres-database.js +4 -1
  8. package/dist/resources/data/redis/resolve-by-app.js +2 -1
  9. package/dist/resources/data/redis/wait-for-ready.d.ts +1 -1
  10. package/dist/resources/data/redis/wait-for-ready.js +3 -2
  11. package/dist/resources/data/transfer/capture-and-wait.js +4 -1
  12. package/dist/resources/data/transfer/restore-and-wait.js +4 -1
  13. package/dist/resources/data/transfer/wait-for-transfer.js +8 -1
  14. package/dist/resources/data/transfer-schedule.js +10 -3
  15. package/dist/resources/extensions/platform.d.ts +3 -0
  16. package/dist/resources/extensions/platform.js +3 -0
  17. package/dist/resources/platform/account.d.ts +22 -0
  18. package/dist/resources/platform/account.js +34 -0
  19. package/dist/resources/platform/add-on/create-and-wait.js +5 -1
  20. package/dist/resources/platform/add-on/describe.js +5 -2
  21. package/dist/resources/platform/add-on/destroy-and-wait.js +2 -3
  22. package/dist/resources/platform/add-on/list-plans.js +8 -2
  23. package/dist/resources/platform/add-on/resolve.js +5 -1
  24. package/dist/resources/platform/add-on/upgrade.js +5 -2
  25. package/dist/resources/platform/add-on/wait-for-provisioning.js +4 -1
  26. package/dist/resources/platform/add-on-attachment/resolve.js +2 -3
  27. package/dist/resources/platform/app/create-and-setup.d.ts +3 -4
  28. package/dist/resources/platform/app/create-and-setup.js +5 -5
  29. package/dist/resources/platform/app/diff.js +2 -3
  30. package/dist/resources/platform/app/index.js +9 -3
  31. package/dist/resources/platform/app/info.d.ts +4 -0
  32. package/dist/resources/platform/app/info.js +21 -7
  33. package/dist/resources/platform/app/transfer.js +2 -1
  34. package/dist/resources/platform/app/wait-for-acm-certificates.js +2 -1
  35. package/dist/resources/platform/container/ensure-container-stack.js +2 -1
  36. package/dist/resources/platform/container/release-images.js +2 -1
  37. package/dist/resources/platform/container/remove-process-types.js +2 -1
  38. package/dist/resources/platform/delinquency.d.ts +23 -0
  39. package/dist/resources/platform/delinquency.js +41 -0
  40. package/dist/resources/platform/domain/clear-custom-domains.js +2 -1
  41. package/dist/resources/platform/domain/create-and-wait.js +2 -1
  42. package/dist/resources/platform/domain/wait-for-ready.js +2 -1
  43. package/dist/resources/platform/dyno/exec.js +4 -3
  44. package/dist/resources/platform/dyno/index.js +6 -3
  45. package/dist/resources/platform/dyno/run.js +2 -1
  46. package/dist/resources/platform/dyno/wait-for-info.js +2 -1
  47. package/dist/resources/platform/dyno/wait-for-release.js +2 -1
  48. package/dist/resources/platform/log-session/index.js +6 -1
  49. package/dist/resources/platform/pipeline/coupling.js +5 -2
  50. package/dist/resources/platform/pipeline/index.js +4 -1
  51. package/dist/resources/platform/pipeline/promotion.js +15 -7
  52. package/dist/resources/platform/review-app-config/resolve-repo-name.js +4 -3
  53. package/dist/resources/platform/sni-endpoint/create-and-associate.js +2 -1
  54. package/dist/resources/platform/space/wait-for-allocated.js +2 -4
  55. package/dist/resources/platform/team.d.ts +22 -0
  56. package/dist/resources/platform/team.js +34 -0
  57. package/dist/resources/platform/telemetry-drain/remove-drains-for-target.js +2 -1
  58. package/dist/resources/platform/test-run/wait-for-state.js +2 -1
  59. package/dist/resources/platform/vpn-connection/wait-for-active.js +2 -1
  60. package/dist/utils/is-abort-error.d.ts +11 -0
  61. package/dist/utils/is-abort-error.js +14 -0
  62. package/package.json +1 -1
@@ -1,5 +1,55 @@
1
1
  import createDebug from 'debug';
2
2
  const debug = createDebug('heroku:sdk:extensions');
3
+ /**
4
+ * Re-derive the extension context so that an operation-wide cancellation set
5
+ * via `sdk.platform.withOptions({signal})` also reaches the clients its
6
+ * extensions issue requests through `ctx`.
7
+ *
8
+ * Extension factories close over `ctx.*` (e.g. `ctx.platform`) to issue their
9
+ * own — often nested, sometimes cross-service — requests. Without this, a signal
10
+ * set on a service client would reach generated route methods but never the
11
+ * requests an extension method makes through `ctx`, so cancellation could not
12
+ * stop the work an extension started. The derivation is applied to *every*
13
+ * service client in `ctx` because cancellation is operation-wide and extensions
14
+ * cross services (e.g. a `data` extension polls through `ctx.platform`).
15
+ *
16
+ * Only the operation-wide `signal` / `timeout` are propagated. A `withHeaders`
17
+ * call, and the `headers` / `body` fields of a `withOptions` call, are
18
+ * endpoint-specific and must not ride cross-service onto unrelated requests
19
+ * (e.g. an `Accept-Expansion` header a sibling endpoint rejects), so they stay
20
+ * on the routes client the caller scoped and are not re-derived here.
21
+ *
22
+ * `withOptions` returns a fresh client without mutating the original, and the
23
+ * derived clients are memoized per key, so sibling derived clients — and the
24
+ * base client — stay isolated from one another.
25
+ */
26
+ function deriveCtx(ctx, method, args) {
27
+ if (method !== 'withOptions')
28
+ return ctx;
29
+ const options = args[0];
30
+ const scoped = {};
31
+ if (options?.signal !== undefined)
32
+ scoped.signal = options.signal;
33
+ if (options?.timeout !== undefined)
34
+ scoped.timeout = options.timeout;
35
+ if (Object.keys(scoped).length === 0)
36
+ return ctx;
37
+ const cache = new Map();
38
+ const derived = {};
39
+ for (const key of Object.keys(ctx)) {
40
+ Object.defineProperty(derived, key, {
41
+ enumerable: true,
42
+ get() {
43
+ if (!cache.has(key)) {
44
+ const client = ctx[key];
45
+ cache.set(key, typeof client?.withOptions === 'function' ? client.withOptions(scoped) : client);
46
+ }
47
+ return cache.get(key);
48
+ },
49
+ });
50
+ }
51
+ return derived;
52
+ }
3
53
  export function mergeExtensions(client, extensions, ctx) {
4
54
  const methodsByResource = new Map();
5
55
  for (const ext of extensions) {
@@ -34,7 +84,7 @@ export function mergeExtensions(client, extensions, ctx) {
34
84
  }
35
85
  return (...args) => {
36
86
  const rewrapped = inner.apply(target, args);
37
- return mergeExtensions(rewrapped, extensions, ctx);
87
+ return mergeExtensions(rewrapped, extensions, deriveCtx(ctx, resourceKey, args));
38
88
  };
39
89
  }
40
90
  const extMethods = methodsByResource.get(resourceKey);
@@ -0,0 +1,19 @@
1
+ import type { RoutesClientExtras } from './create-client.js';
2
+ /**
3
+ * Bind a cancellation signal to a routes client so the requests it issues
4
+ * abort in flight.
5
+ *
6
+ * Extension methods receive their signal as a per-call option (e.g.
7
+ * `addOn.createAndWait(app, body, {signal})`) rather than through the client,
8
+ * so the signal reaches `wait()`/`throwIfAborted()` checkpoints but not the
9
+ * in-flight request unless it's pushed onto the client. This returns a derived,
10
+ * signal-bearing client (the original is never mutated) — or the client
11
+ * unchanged when no signal is supplied, so a signal-less call stays a no-op.
12
+ *
13
+ * Signals do not combine: `withOptions` is last-wins, so binding a per-call
14
+ * signal here replaces any signal already set on the client (e.g. a
15
+ * session-level one) for the requests this derived client issues. A caller that
16
+ * needs both to abort the request must combine them itself (e.g.
17
+ * `AbortSignal.any([sessionSignal, callSignal])`) before passing one in.
18
+ */
19
+ export declare function withSignal<T>(client: RoutesClientExtras<T> & T, signal?: AbortSignal): RoutesClientExtras<T> & T;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Bind a cancellation signal to a routes client so the requests it issues
3
+ * abort in flight.
4
+ *
5
+ * Extension methods receive their signal as a per-call option (e.g.
6
+ * `addOn.createAndWait(app, body, {signal})`) rather than through the client,
7
+ * so the signal reaches `wait()`/`throwIfAborted()` checkpoints but not the
8
+ * in-flight request unless it's pushed onto the client. This returns a derived,
9
+ * signal-bearing client (the original is never mutated) — or the client
10
+ * unchanged when no signal is supplied, so a signal-less call stays a no-op.
11
+ *
12
+ * Signals do not combine: `withOptions` is last-wins, so binding a per-call
13
+ * signal here replaces any signal already set on the client (e.g. a
14
+ * session-level one) for the requests this derived client issues. A caller that
15
+ * needs both to abort the request must combine them itself (e.g.
16
+ * `AbortSignal.any([sessionSignal, callSignal])`) before passing one in.
17
+ */
18
+ export function withSignal(client, signal) {
19
+ return signal ? client.withOptions({ signal }) : client;
20
+ }
@@ -1,34 +1,47 @@
1
1
  import { extendResource } from '../../core/extend-resource.js';
2
+ import { withSignal } from '../../core/with-signal.js';
2
3
  import { resolvePgDatabase } from './internal/resolve-pg-database.js';
3
4
  export async function describe(ctx, appIdentity, addonIdentity, options = {}) {
4
5
  options.signal?.throwIfAborted();
5
6
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
6
- return ctx.data.database.info(addon.id);
7
+ // Bind the signal so the request aborts in flight, not just during resolution.
8
+ const data = withSignal(ctx.data, options.signal);
9
+ return data.database.info(addon.id);
7
10
  }
8
11
  export async function upgradeWaitStatus(ctx, appIdentity, addonIdentity, options = {}) {
9
12
  options.signal?.throwIfAborted();
10
13
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
11
- return ctx.data.database.upgradeWaitStatus(addon.id);
14
+ // Bind the signal so the request aborts in flight, not just during resolution.
15
+ const data = withSignal(ctx.data, options.signal);
16
+ return data.database.upgradeWaitStatus(addon.id);
12
17
  }
13
18
  export async function dryRunUpgrade(ctx, appIdentity, addonIdentity, body = {}, options = {}) {
14
19
  options.signal?.throwIfAborted();
15
20
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
16
- return ctx.data.database.dryRunUpgrade(addon.id, body);
21
+ // Bind the signal so the request aborts in flight, not just during resolution.
22
+ const data = withSignal(ctx.data, options.signal);
23
+ return data.database.dryRunUpgrade(addon.id, body);
17
24
  }
18
25
  export async function runUpgrade(ctx, appIdentity, addonIdentity, body = {}, options = {}) {
19
26
  options.signal?.throwIfAborted();
20
27
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
21
- return ctx.data.database.runUpgrade(addon.id, body);
28
+ // Bind the signal so the request aborts in flight, not just during resolution.
29
+ const data = withSignal(ctx.data, options.signal);
30
+ return data.database.runUpgrade(addon.id, body);
22
31
  }
23
32
  export async function prepareUpgrade(ctx, appIdentity, addonIdentity, body = {}, options = {}) {
24
33
  options.signal?.throwIfAborted();
25
34
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
26
- return ctx.data.database.prepareUpgrade(addon.id, body);
35
+ // Bind the signal so the request aborts in flight, not just during resolution.
36
+ const data = withSignal(ctx.data, options.signal);
37
+ return data.database.prepareUpgrade(addon.id, body);
27
38
  }
28
39
  export async function cancelUpgrade(ctx, appIdentity, addonIdentity, options = {}) {
29
40
  options.signal?.throwIfAborted();
30
41
  const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
31
- return ctx.data.database.cancelUpgrade(addon.id);
42
+ // Bind the signal so the request aborts in flight, not just during resolution.
43
+ const data = withSignal(ctx.data, options.signal);
44
+ return data.database.cancelUpgrade(addon.id);
32
45
  }
33
46
  export const databaseExtensions = extendResource('data', 'database', ctx => ({
34
47
  cancelUpgrade: (appIdentity, addonIdentity, options) => cancelUpgrade(ctx, appIdentity, addonIdentity, options),
@@ -1,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';
@@ -0,0 +1,22 @@
1
+ import type { Account, AccountDelinquency } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../core/extend-resource.js';
3
+ export type GetAccountWithDelinquencyOptions = {
4
+ signal?: AbortSignal;
5
+ };
6
+ export type AccountWithDelinquency = {
7
+ account: Account;
8
+ delinquency: AccountDelinquency | null;
9
+ };
10
+ /**
11
+ * Fetch the authenticated account together with its delinquency status.
12
+ *
13
+ * Runs the two reads concurrently. The account 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 account fetch (it
16
+ * backs commands like `whoami`). Callers decide whether and how to surface a
17
+ * warning from `delinquency` — `formatDelinquencyWarning` renders the sentence.
18
+ */
19
+ export declare function getAccountWithDelinquency(ctx: Pick<ResourceCtx, 'platform'>, options?: GetAccountWithDelinquencyOptions): Promise<AccountWithDelinquency>;
20
+ export declare const accountExtensions: import("../../core/extend-resource.js").ResourceExtension<"platform", "account", {
21
+ getWithDelinquency: (options?: GetAccountWithDelinquencyOptions) => Promise<AccountWithDelinquency>;
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:account');
4
+ /**
5
+ * Fetch the authenticated account together with its delinquency status.
6
+ *
7
+ * Runs the two reads concurrently. The account 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 account fetch (it
10
+ * backs commands like `whoami`). Callers decide whether and how to surface a
11
+ * warning from `delinquency` — `formatDelinquencyWarning` renders the sentence.
12
+ */
13
+ export async function getAccountWithDelinquency(ctx, options = {}) {
14
+ options.signal?.throwIfAborted();
15
+ const platform = options.signal
16
+ ? ctx.platform.withOptions({ signal: options.signal })
17
+ : ctx.platform;
18
+ const [account, delinquency] = await Promise.all([
19
+ platform.account.info(),
20
+ platform.accountDelinquency.info().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
23
+ // account as current. Anything else is logged and swallowed.
24
+ if (options.signal?.aborted)
25
+ throw error;
26
+ debug('account delinquency lookup failed; treating as not delinquent: %O', error);
27
+ return null;
28
+ }),
29
+ ]);
30
+ return { account, delinquency };
31
+ }
32
+ export const accountExtensions = extendResource('platform', 'account', ctx => ({
33
+ getWithDelinquency: (options) => getAccountWithDelinquency(ctx, options),
34
+ }));
@@ -1,4 +1,5 @@
1
1
  import { HerokuApiError } from '@heroku/heroku-fetch';
2
+ import { withSignal } from '../../../core/with-signal.js';
2
3
  import { wait } from '../../../utils/wait.js';
3
4
  import { AddonConfirmationRequiredError, AddonProvisioningFailedError } from './errors.js';
4
5
  const DEFAULT_CREATE_WAIT_INTERVAL_MS = 5000;
@@ -28,7 +29,10 @@ const DEFAULT_CREATE_WAIT_INTERVAL_MS = 5000;
28
29
  */
29
30
  export async function createAndWait(ctx, appIdentity, body, options = {}) {
30
31
  options.signal?.throwIfAborted();
31
- const platform = ctx.platform.withHeaders({ 'Accept-Expansion': 'addon_service,plan' });
32
+ // Bind the signal to the client so the create and poll requests abort in
33
+ // flight, not just the delay between polls.
34
+ const platform = withSignal(ctx.platform, options.signal)
35
+ .withHeaders({ 'Accept-Expansion': 'addon_service,plan' });
32
36
  let addon;
33
37
  try {
34
38
  addon = await platform
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { resolveAddonInternal } from './resolve.js';
2
3
  /**
3
4
  * Resolve, describe, and decorate an add-on for display.
@@ -13,11 +14,13 @@ import { resolveAddonInternal } from './resolve.js';
13
14
  */
14
15
  export async function describeAddon(ctx, addonIdentity, options = {}) {
15
16
  options.signal?.throwIfAborted();
17
+ // Bind the signal so both requests abort in flight, not just at the checkpoints.
18
+ const base = withSignal(ctx.platform, options.signal);
16
19
  // Accept-Expansion is scoped to the resolve call (addOn.resolution
17
20
  // serializer accepts addon_service + plan expansions). The
18
21
  // /addons/:id/addon-attachments endpoint uses Accept-Inclusion, not
19
22
  // Accept-Expansion, and rejects an unknown expansion header.
20
- const resolvingPlatform = ctx.platform.withHeaders({
23
+ const resolvingPlatform = base.withHeaders({
21
24
  Accept: 'application/vnd.heroku+json; version=3.sdk',
22
25
  'Accept-Expansion': 'addon_service,plan',
23
26
  });
@@ -26,7 +29,7 @@ export async function describeAddon(ctx, addonIdentity, options = {}) {
26
29
  appIdentity: options.appIdentity,
27
30
  });
28
31
  options.signal?.throwIfAborted();
29
- const attachments = await ctx.platform.addOnAttachment.listByAddOn(addon.id);
32
+ const attachments = await base.addOnAttachment.listByAddOn(addon.id);
30
33
  const plan = addon.plan;
31
34
  return {
32
35
  ...addon,
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { wait } from '../../../utils/wait.js';
2
3
  import { AddonProvisioningFailedError } from './errors.js';
3
4
  const DEFAULT_DESTROY_WAIT_INTERVAL_MS = 5000;
@@ -21,9 +22,7 @@ const DEFAULT_DESTROY_WAIT_INTERVAL_MS = 5000;
21
22
  */
22
23
  export async function destroyAndWait(ctx, appIdentity, addonIdentity, options = {}) {
23
24
  options.signal?.throwIfAborted();
24
- const platform = ctx.platform
25
- .withHeaders({ 'Accept-Expansion': 'plan' })
26
- .withOptions({ signal: options.signal });
25
+ const platform = withSignal(ctx.platform.withHeaders({ 'Accept-Expansion': 'plan' }), options.signal);
27
26
  // The force body is scoped to the delete call only — a sticky body on the
28
27
  // shared client would leak onto the GET poll requests below.
29
28
  const deleteClient = options.force
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { AddonNotFoundError } from './errors.js';
2
3
  import { resolveAddonInternal } from './resolve.js';
3
4
  /**
@@ -7,7 +8,9 @@ import { resolveAddonInternal } from './resolve.js';
7
8
  */
8
9
  export async function listPlans(ctx, serviceIdentity, options = {}) {
9
10
  options.signal?.throwIfAborted();
10
- const plans = await ctx.platform.plan.listByAddOn(serviceIdentity);
11
+ // Bind the signal so the request aborts in flight.
12
+ const platform = withSignal(ctx.platform, options.signal);
13
+ const plans = await platform.plan.listByAddOn(serviceIdentity);
11
14
  return [...plans].sort(byPriceCentsAsc);
12
15
  }
13
16
  /**
@@ -26,7 +29,10 @@ export async function listPlans(ctx, serviceIdentity, options = {}) {
26
29
  */
27
30
  export async function listPlansForAddon(ctx, addonIdentity, options = {}) {
28
31
  options.signal?.throwIfAborted();
29
- const addon = await resolveAddonInternal(ctx.platform, addonIdentity, {
32
+ // Bind the signal so the resolve request aborts in flight (the helper itself
33
+ // doesn't thread options.signal into the client).
34
+ const platform = withSignal(ctx.platform, options.signal);
35
+ const addon = await resolveAddonInternal(platform, addonIdentity, {
30
36
  appIdentity: options.appIdentity,
31
37
  });
32
38
  // The route accepts either name or id; name is stable across
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { debug } from './debug.js';
2
3
  import { AddonAmbiguousError, AddonNotFoundError } from './errors.js';
3
4
  /**
@@ -25,7 +26,10 @@ import { AddonAmbiguousError, AddonNotFoundError } from './errors.js';
25
26
  */
26
27
  export async function resolveAddon(ctx, addonIdentity, options = {}) {
27
28
  options.signal?.throwIfAborted();
28
- return resolveAddonInternal(ctx.platform, addonIdentity, options);
29
+ // Bind the signal so the resolve request aborts in flight (the helper itself
30
+ // doesn't thread options.signal into the client).
31
+ const platform = withSignal(ctx.platform, options.signal);
32
+ return resolveAddonInternal(platform, addonIdentity, options);
29
33
  }
30
34
  export async function resolveAddonInternal(platform, addonIdentity, options = {}) {
31
35
  const { addonService, appIdentity } = options;
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { debug } from './debug.js';
2
3
  import { resolveAddonInternal } from './resolve.js';
3
4
  /**
@@ -18,7 +19,9 @@ import { resolveAddonInternal } from './resolve.js';
18
19
  */
19
20
  export async function upgrade(ctx, addonIdentity, plan, options = {}) {
20
21
  options.signal?.throwIfAborted();
21
- const addon = await resolveAddonInternal(ctx.platform, addonIdentity, {
22
+ // Bind the signal so the resolve and update requests abort in flight.
23
+ const platform = withSignal(ctx.platform, options.signal);
24
+ const addon = await resolveAddonInternal(platform, addonIdentity, {
22
25
  addonService: options.addonService,
23
26
  appIdentity: options.appIdentity,
24
27
  });
@@ -31,5 +34,5 @@ export async function upgrade(ctx, addonIdentity, plan, options = {}) {
31
34
  debug('upgrade plan qualified plan=%s qualified=%s', plan, qualifiedPlan);
32
35
  }
33
36
  debug('upgrade addon=%s app=%s plan=%s', addon.id, addon.app.id, qualifiedPlan);
34
- return ctx.platform.addOn.update(addon.app.id, addon.id, { plan: qualifiedPlan });
37
+ return platform.addOn.update(addon.app.id, addon.id, { plan: qualifiedPlan });
35
38
  }
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { wait } from '../../../utils/wait.js';
2
3
  import { AddonProvisioningFailedError } from './errors.js';
3
4
  const DEFAULT_WAIT_INTERVAL_MS = 5000;
@@ -19,7 +20,9 @@ const DEFAULT_WAIT_INTERVAL_MS = 5000;
19
20
  export async function waitForProvisioning(ctx, addon, options = {}) {
20
21
  options.signal?.throwIfAborted();
21
22
  const intervalMs = options.waitIntervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
22
- const platform = ctx.platform.withHeaders({ 'Accept-Expansion': 'addon_service,plan' });
23
+ // Bind the signal so each poll request aborts in flight, not just the delay.
24
+ const platform = withSignal(ctx.platform, options.signal)
25
+ .withHeaders({ 'Accept-Expansion': 'addon_service,plan' });
23
26
  let current = addon;
24
27
  /* eslint-disable no-await-in-loop */
25
28
  while (current.state === 'provisioning' || current.state === 'deprovisioning') {