@heroku/sdk 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE.txt +207 -0
  2. package/README.md +94 -0
  3. package/dist/compositions/add-on.d.ts +7 -0
  4. package/dist/compositions/add-on.js +6 -0
  5. package/dist/compositions/app.d.ts +8 -0
  6. package/dist/compositions/app.js +11 -0
  7. package/dist/compositions/dyno.d.ts +17 -0
  8. package/dist/compositions/dyno.js +23 -0
  9. package/dist/compositions/pg.d.ts +15 -0
  10. package/dist/compositions/pg.js +56 -0
  11. package/dist/compositions/pipeline.d.ts +33 -0
  12. package/dist/compositions/pipeline.js +146 -0
  13. package/dist/core/create-client.d.ts +32 -0
  14. package/dist/core/create-client.js +48 -0
  15. package/dist/core/dispatcher.d.ts +3 -0
  16. package/dist/core/dispatcher.js +80 -0
  17. package/dist/core/extend-resource.d.ts +24 -0
  18. package/dist/core/extend-resource.js +3 -0
  19. package/dist/core/extensions-proxy.d.ts +2 -0
  20. package/dist/core/extensions-proxy.js +56 -0
  21. package/dist/core/heroku-sdk.d.ts +14 -0
  22. package/dist/core/heroku-sdk.js +54 -0
  23. package/dist/core/interpolate-path.d.ts +3 -0
  24. package/dist/core/interpolate-path.js +13 -0
  25. package/dist/index.d.ts +3 -0
  26. package/dist/index.js +2 -0
  27. package/dist/resources/data/backup.d.ts +9 -0
  28. package/dist/resources/data/backup.js +10 -0
  29. package/dist/resources/data/database.d.ts +54 -0
  30. package/dist/resources/data/database.js +44 -0
  31. package/dist/resources/data/internal/resolve-addon-id.d.ts +2 -0
  32. package/dist/resources/data/internal/resolve-addon-id.js +13 -0
  33. package/dist/resources/data/internal/resolve-pg-database.d.ts +23 -0
  34. package/dist/resources/data/internal/resolve-pg-database.js +52 -0
  35. package/dist/resources/data/maintenance.d.ts +9 -0
  36. package/dist/resources/data/maintenance.js +10 -0
  37. package/dist/resources/data/postgres-credential.d.ts +11 -0
  38. package/dist/resources/data/postgres-credential.js +16 -0
  39. package/dist/resources/data/postgres-database.d.ts +32 -0
  40. package/dist/resources/data/postgres-database.js +14 -0
  41. package/dist/resources/data/postgres.d.ts +9 -0
  42. package/dist/resources/data/postgres.js +10 -0
  43. package/dist/resources/data/transfer-schedule.d.ts +9 -0
  44. package/dist/resources/data/transfer-schedule.js +10 -0
  45. package/dist/resources/data/transfer.d.ts +49 -0
  46. package/dist/resources/data/transfer.js +14 -0
  47. package/dist/resources/extensions/data.d.ts +4 -0
  48. package/dist/resources/extensions/data.js +4 -0
  49. package/dist/resources/extensions/platform.d.ts +8 -0
  50. package/dist/resources/extensions/platform.js +8 -0
  51. package/dist/resources/platform/add-on/create-and-wait.d.ts +28 -0
  52. package/dist/resources/platform/add-on/create-and-wait.js +63 -0
  53. package/dist/resources/platform/add-on/debug.d.ts +2 -0
  54. package/dist/resources/platform/add-on/debug.js +2 -0
  55. package/dist/resources/platform/add-on/describe.d.ts +20 -0
  56. package/dist/resources/platform/add-on/describe.js +51 -0
  57. package/dist/resources/platform/add-on/errors.d.ts +51 -0
  58. package/dist/resources/platform/add-on/errors.js +61 -0
  59. package/dist/resources/platform/add-on/index.d.ts +23 -0
  60. package/dist/resources/platform/add-on/index.js +25 -0
  61. package/dist/resources/platform/add-on/list-plans.d.ts +34 -0
  62. package/dist/resources/platform/add-on/list-plans.js +53 -0
  63. package/dist/resources/platform/add-on/pricing.d.ts +72 -0
  64. package/dist/resources/platform/add-on/pricing.js +100 -0
  65. package/dist/resources/platform/add-on/resolve.d.ts +43 -0
  66. package/dist/resources/platform/add-on/resolve.js +103 -0
  67. package/dist/resources/platform/add-on/types.d.ts +49 -0
  68. package/dist/resources/platform/add-on/types.js +1 -0
  69. package/dist/resources/platform/add-on/upgrade.d.ts +21 -0
  70. package/dist/resources/platform/add-on/upgrade.js +36 -0
  71. package/dist/resources/platform/add-on.d.ts +211 -0
  72. package/dist/resources/platform/add-on.js +354 -0
  73. package/dist/resources/platform/app/index.d.ts +58 -0
  74. package/dist/resources/platform/app/index.js +53 -0
  75. package/dist/resources/platform/app/info.d.ts +30 -0
  76. package/dist/resources/platform/app/info.js +27 -0
  77. package/dist/resources/platform/app.d.ts +56 -0
  78. package/dist/resources/platform/app.js +50 -0
  79. package/dist/resources/platform/dyno/index.d.ts +45 -0
  80. package/dist/resources/platform/dyno/index.js +26 -0
  81. package/dist/resources/platform/dyno.d.ts +45 -0
  82. package/dist/resources/platform/dyno.js +26 -0
  83. package/dist/resources/platform/log-session/index.d.ts +102 -0
  84. package/dist/resources/platform/log-session/index.js +223 -0
  85. package/dist/resources/platform/log-session/parser.d.ts +74 -0
  86. package/dist/resources/platform/log-session/parser.js +71 -0
  87. package/dist/resources/platform/log-session.d.ts +81 -0
  88. package/dist/resources/platform/log-session.js +167 -0
  89. package/dist/resources/platform/pipeline/coupling.d.ts +27 -0
  90. package/dist/resources/platform/pipeline/coupling.js +55 -0
  91. package/dist/resources/platform/pipeline/index.d.ts +53 -0
  92. package/dist/resources/platform/pipeline/index.js +75 -0
  93. package/dist/resources/platform/pipeline/promotion.d.ts +21 -0
  94. package/dist/resources/platform/pipeline/promotion.js +109 -0
  95. package/dist/resources/platform/pipeline-coupling.d.ts +27 -0
  96. package/dist/resources/platform/pipeline-coupling.js +55 -0
  97. package/dist/resources/platform/pipeline-promotion.d.ts +21 -0
  98. package/dist/resources/platform/pipeline-promotion.js +128 -0
  99. package/dist/resources/platform/pipeline.d.ts +53 -0
  100. package/dist/resources/platform/pipeline.js +75 -0
  101. package/dist/resources/platform/shield.d.ts +2 -0
  102. package/dist/resources/platform/shield.js +6 -0
  103. package/dist/services/data.d.ts +5 -0
  104. package/dist/services/data.js +5 -0
  105. package/dist/services/platform.d.ts +5 -0
  106. package/dist/services/platform.js +5 -0
  107. package/dist/utils/wait.d.ts +16 -0
  108. package/dist/utils/wait.js +35 -0
  109. package/package.json +54 -0
@@ -0,0 +1,167 @@
1
+ import { HerokuApiClient } from '@heroku/heroku-fetch';
2
+ import createDebug from 'debug';
3
+ import { extendResource } from '../../core/extend-resource.js';
4
+ import { getGeneration } from './app.js';
5
+ const debug = createDebug('heroku:sdk:resources:log-session');
6
+ const DEFAULT_FIR_SESSION_TIMEOUT_MS = 15 * 60 * 1000;
7
+ const FIR_TIMEOUT_ERROR_MESSAGE = 'Fir log stream timeout';
8
+ /**
9
+ * Stream logs for an app, yielding one line at a time.
10
+ *
11
+ * Creates a log session via `logSession.create`, opens the resulting
12
+ * `logplex_url`, and parses the chunked plain-text response into
13
+ * newline-delimited entries. Use `tail: true` to keep the stream
14
+ * open; `recreateSession: true` (the default when tailing) makes the
15
+ * SDK transparently re-open a new session after the platform's idle
16
+ * timeout, so the iterator keeps yielding without the caller having
17
+ * to handle the reconnect.
18
+ *
19
+ * The iterator terminates cleanly when:
20
+ * - the platform closes a non-tailing stream (all lines yielded)
21
+ * - `signal` is aborted (an `AbortError` propagates)
22
+ * - `recreateSession` is false and the platform closes a tail
23
+ *
24
+ * The iterator throws on:
25
+ * - non-2xx response from the logplex URL
26
+ * - errors thrown by `logSession.create` (auth, app-not-found, etc.)
27
+ */
28
+ export async function* streamLogs(ctx, appIdentity, options = {}) {
29
+ const { dyno, lines, sessionTimeoutMs = DEFAULT_FIR_SESSION_TIMEOUT_MS, signal, source, tail = false, type, } = options;
30
+ signal?.throwIfAborted();
31
+ const generation = await getGeneration(ctx, appIdentity, { signal });
32
+ const isFir = generation === 'fir';
33
+ // Fir doesn't support a bounded session; the platform always streams.
34
+ const effectiveTail = isFir ? true : tail;
35
+ const recreateSession = options.recreateSession ?? effectiveTail;
36
+ const createOpts = buildCreateOpts({
37
+ dyno, isFir, lines, source, tail: effectiveTail, type,
38
+ });
39
+ // The recreate loop is inherently sequential: each session must
40
+ // close before we ask for the next one.
41
+ /* eslint-disable no-await-in-loop */
42
+ let isRecreate = false;
43
+ while (true) {
44
+ debug('streamLogs creating session app=%s generation=%s tail=%s', appIdentity, generation ?? '<unknown>', effectiveTail);
45
+ const session = await ctx.platform.logSession.create(appIdentity, createOpts);
46
+ if (!session.logplex_url) {
47
+ throw new Error('Log session response did not include a logplex_url.');
48
+ }
49
+ await options.onSessionCreated?.({ generation, isRecreate });
50
+ isRecreate = true;
51
+ debug('streamLogs session=%s opening stream', session.id);
52
+ let timedOut = false;
53
+ let timeoutHandle;
54
+ try {
55
+ // Logplex URLs hit a region-specific stream host (e.g.
56
+ // virginia.sessions.logs.heroku.com). The token is embedded in
57
+ // the URL, so we use a custom-service client with no auth and
58
+ // a per-call baseUrl; this also picks up heroku-fetch's
59
+ // env-var proxy support.
60
+ const parsed = new URL(session.logplex_url);
61
+ const logplexClient = new HerokuApiClient({
62
+ baseUrl: parsed.origin,
63
+ service: 'custom',
64
+ token: '',
65
+ });
66
+ const response = await logplexClient.stream(`${parsed.pathname}${parsed.search}`);
67
+ if (!response.body) {
68
+ throw new Error('Logplex stream returned no body.');
69
+ }
70
+ const reader = response.body.getReader();
71
+ const decoder = new TextDecoder();
72
+ let pending = '';
73
+ // Force a recreate after sessionTimeoutMs of silence; ignored
74
+ // when not tailing.
75
+ const armTimeout = () => {
76
+ if (!recreateSession)
77
+ return;
78
+ if (timeoutHandle)
79
+ clearTimeout(timeoutHandle);
80
+ timeoutHandle = setTimeout(() => {
81
+ timedOut = true;
82
+ debug('streamLogs session=%s timed out after %dms', session.id, sessionTimeoutMs);
83
+ reader.cancel().catch(() => { });
84
+ }, sessionTimeoutMs);
85
+ };
86
+ armTimeout();
87
+ try {
88
+ while (true) {
89
+ const { done, value } = await reader.read();
90
+ if (done)
91
+ break;
92
+ armTimeout();
93
+ pending += decoder.decode(value, { stream: true });
94
+ let newlineIndex = pending.indexOf('\n');
95
+ while (newlineIndex !== -1) {
96
+ const line = pending.slice(0, newlineIndex);
97
+ pending = pending.slice(newlineIndex + 1);
98
+ if (line.length > 0)
99
+ yield line;
100
+ newlineIndex = pending.indexOf('\n');
101
+ }
102
+ }
103
+ // Flush any trailing partial line that didn't end in '\n'.
104
+ const trailing = pending + decoder.decode();
105
+ if (trailing.length > 0)
106
+ yield trailing;
107
+ }
108
+ finally {
109
+ if (timeoutHandle)
110
+ clearTimeout(timeoutHandle);
111
+ reader.releaseLock();
112
+ }
113
+ if (timedOut) {
114
+ debug('streamLogs session=%s recreating after timeout', session.id);
115
+ continue;
116
+ }
117
+ if (!recreateSession) {
118
+ return;
119
+ }
120
+ debug('streamLogs session=%s remote closed; recreating', session.id);
121
+ }
122
+ catch (error) {
123
+ if (timeoutHandle)
124
+ clearTimeout(timeoutHandle);
125
+ if (signal?.aborted)
126
+ throw error;
127
+ if (timedOut)
128
+ continue;
129
+ // Translate Fir's documented timeout error message into a recreate.
130
+ if (error instanceof Error && error.message === FIR_TIMEOUT_ERROR_MESSAGE && recreateSession) {
131
+ continue;
132
+ }
133
+ throw error;
134
+ }
135
+ }
136
+ /* eslint-enable no-await-in-loop */
137
+ }
138
+ /**
139
+ * Translate the SDK's consumer-facing options into the shape the
140
+ * platform's `log-sessions` endpoint expects per generation:
141
+ * - Cedar's session takes a single `dyno` field that historically
142
+ * accepted either a process type or a specific dyno; we collapse
143
+ * `dyno || type` for the caller. `lines` and `tail` are honored.
144
+ * - Fir's session takes `dyno` and `type` separately and ignores
145
+ * `lines`; the stream always tails.
146
+ */
147
+ function buildCreateOpts(options) {
148
+ const createOpts = { source: options.source };
149
+ if (options.isFir) {
150
+ if (options.dyno)
151
+ createOpts.dyno = options.dyno;
152
+ if (options.type)
153
+ createOpts.type = options.type;
154
+ }
155
+ else {
156
+ const cedarDyno = options.dyno ?? options.type;
157
+ if (cedarDyno)
158
+ createOpts.dyno = cedarDyno;
159
+ if (options.lines !== undefined)
160
+ createOpts.lines = options.lines;
161
+ createOpts.tail = options.tail;
162
+ }
163
+ return createOpts;
164
+ }
165
+ export const logSessionExtensions = extendResource('platform', 'logSession', ctx => ({
166
+ streamLogs: (appIdentity, options) => streamLogs(ctx, appIdentity, options),
167
+ }));
@@ -0,0 +1,27 @@
1
+ import type { PipelineCoupling, TeamApp } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ export type PipelineWarning = {
4
+ limit: number;
5
+ pipelineId: string;
6
+ type: 'apps_truncated';
7
+ };
8
+ export type ListPipelineAppsOptions = {
9
+ onWarning?: (warning: PipelineWarning) => void;
10
+ signal?: AbortSignal;
11
+ };
12
+ export type AppWithPipelineCoupling = TeamApp & {
13
+ pipelineCoupling: PipelineCoupling;
14
+ };
15
+ /**
16
+ * List the apps coupled to a pipeline, each decorated with its
17
+ * `PipelineCoupling`.
18
+ *
19
+ * Couplings missing an app id are dropped before issuing the bulk
20
+ * filter call. If the pipeline has more than 1000 couplings, an
21
+ * `onWarning` handler must be supplied to opt into a truncated
22
+ * result; otherwise this throws.
23
+ */
24
+ export declare function listPipelineApps(ctx: Pick<ResourceCtx, 'platform'>, pipelineId: string, options?: ListPipelineAppsOptions): Promise<AppWithPipelineCoupling[]>;
25
+ export declare const pipelineCouplingExtensions: import("../../../index.js").ResourceExtension<"platform", "pipelineCoupling", {
26
+ listApps: (pipelineId: string, options?: ListPipelineAppsOptions) => Promise<AppWithPipelineCoupling[]>;
27
+ }>;
@@ -0,0 +1,55 @@
1
+ import createDebug from 'debug';
2
+ import { extendResource } from '../../../core/extend-resource.js';
3
+ const debug = createDebug('heroku:sdk:resources:pipeline-coupling');
4
+ const APPS_FILTER_LIMIT = 1000;
5
+ /**
6
+ * List the apps coupled to a pipeline, each decorated with its
7
+ * `PipelineCoupling`.
8
+ *
9
+ * Couplings missing an app id are dropped before issuing the bulk
10
+ * filter call. If the pipeline has more than 1000 couplings, an
11
+ * `onWarning` handler must be supplied to opt into a truncated
12
+ * result; otherwise this throws.
13
+ */
14
+ export async function listPipelineApps(ctx, pipelineId, options = {}) {
15
+ options.signal?.throwIfAborted();
16
+ const allCouplings = await ctx.platform.pipelineCoupling.listByPipeline(pipelineId);
17
+ // Drop malformed couplings (no app id) before issuing the bulk filter call.
18
+ const couplings = allCouplings.filter(coupling => coupling.app?.id);
19
+ const skipped = allCouplings.length - couplings.length;
20
+ if (skipped > 0) {
21
+ debug('listPipelineApps pipeline=%s skipped=%d couplings without app.id', pipelineId, skipped);
22
+ }
23
+ if (couplings.length === 0) {
24
+ return [];
25
+ }
26
+ let couplingsToResolve = couplings;
27
+ if (couplings.length > APPS_FILTER_LIMIT) {
28
+ if (!options.onWarning) {
29
+ throw new Error(`Pipeline ${pipelineId} has more than ${APPS_FILTER_LIMIT} apps. `
30
+ + 'Pass an onWarning handler to opt into a truncated result.');
31
+ }
32
+ debug('listPipelineApps pipeline=%s truncating couplings=%d limit=%d', pipelineId, couplings.length, APPS_FILTER_LIMIT);
33
+ options.onWarning({ limit: APPS_FILTER_LIMIT, pipelineId, type: 'apps_truncated' });
34
+ couplingsToResolve = couplings.slice(0, APPS_FILTER_LIMIT);
35
+ }
36
+ options.signal?.throwIfAborted();
37
+ const ids = couplingsToResolve.map(coupling => coupling.app.id);
38
+ const apps = await ctx.platform
39
+ .withHeaders({ Range: `id ..; max=${APPS_FILTER_LIMIT};` })
40
+ .filterApps.apps({ in: { id: ids } });
41
+ if (apps.length !== ids.length) {
42
+ debug('listPipelineApps pipeline=%s requested=%d returned=%d', pipelineId, ids.length, apps.length);
43
+ }
44
+ const couplingByAppId = new Map();
45
+ for (const coupling of couplingsToResolve) {
46
+ couplingByAppId.set(coupling.app.id, coupling);
47
+ }
48
+ return apps.map(app => ({
49
+ ...app,
50
+ pipelineCoupling: couplingByAppId.get(app.id),
51
+ }));
52
+ }
53
+ export const pipelineCouplingExtensions = extendResource('platform', 'pipelineCoupling', ctx => ({
54
+ listApps: (pipelineId, options) => listPipelineApps(ctx, pipelineId, options),
55
+ }));
@@ -0,0 +1,53 @@
1
+ import type { Pipeline } from '@heroku/types/3.sdk';
2
+ import { type HerokuApiClientOptions } from '@heroku/heroku-fetch';
3
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
4
+ export type ResolvePipelineOptions = {
5
+ clientOptions?: HerokuApiClientOptions;
6
+ signal?: AbortSignal;
7
+ };
8
+ export declare class PipelineNotFoundError extends Error {
9
+ readonly identity: string;
10
+ readonly id = "not_found";
11
+ readonly statusCode = 404;
12
+ constructor(identity: string);
13
+ get body(): {
14
+ id: string;
15
+ message: string;
16
+ resource: string;
17
+ };
18
+ }
19
+ export declare class PipelineAmbiguousError extends Error {
20
+ readonly identity: string;
21
+ readonly matches: Pipeline[];
22
+ readonly id = "multiple_matches";
23
+ readonly statusCode = 422;
24
+ constructor(identity: string, matches: Pipeline[]);
25
+ get body(): {
26
+ id: string;
27
+ message: string;
28
+ };
29
+ }
30
+ /**
31
+ * Resolve a Pipeline by identity.
32
+ *
33
+ * The identity may be:
34
+ * - a UUID — fetched directly via `pipeline.info`.
35
+ * - a name — looked up via `GET /pipelines?eq[name]=<name>`. Pipeline
36
+ * names are not unique platform-wide, so a name lookup may return
37
+ * zero, one, or many matches.
38
+ *
39
+ * The name path uses a raw `HerokuApiClient` because the route
40
+ * registry's `pipeline.list` doesn't accept query params, and we don't
41
+ * want to bake those params into the routes-generated client (where
42
+ * they would leak across unrelated calls).
43
+ *
44
+ * Errors:
45
+ * - throws `PipelineNotFoundError` when no pipeline matches the name.
46
+ * - throws `PipelineAmbiguousError` when more than one does. Callers
47
+ * that want to prompt the user should catch this and inspect
48
+ * `error.matches`.
49
+ */
50
+ export declare function resolvePipeline(ctx: Pick<ResourceCtx, 'platform'>, identity: string, options?: ResolvePipelineOptions): Promise<Pipeline>;
51
+ export declare const pipelineExtensions: import("../../../index.js").ResourceExtension<"platform", "pipeline", {
52
+ resolve: (identity: string, options?: ResolvePipelineOptions) => Promise<Pipeline>;
53
+ }>;
@@ -0,0 +1,75 @@
1
+ import { HerokuApiClient } from '@heroku/heroku-fetch';
2
+ import { extendResource } from '../../../core/extend-resource.js';
3
+ export class PipelineNotFoundError extends Error {
4
+ identity;
5
+ id = 'not_found';
6
+ statusCode = 404;
7
+ constructor(identity) {
8
+ super(`Couldn't find a pipeline matching '${identity}'.`);
9
+ this.identity = identity;
10
+ this.name = 'PipelineNotFoundError';
11
+ }
12
+ get body() {
13
+ return { id: this.id, message: this.message, resource: 'pipeline' };
14
+ }
15
+ }
16
+ export class PipelineAmbiguousError extends Error {
17
+ identity;
18
+ matches;
19
+ id = 'multiple_matches';
20
+ statusCode = 422;
21
+ constructor(identity, matches) {
22
+ super(`Ambiguous identifier; multiple pipelines named '${identity}' found (${matches.length} matches).`);
23
+ this.identity = identity;
24
+ this.matches = matches;
25
+ this.name = 'PipelineAmbiguousError';
26
+ }
27
+ get body() {
28
+ return { id: this.id, message: this.message };
29
+ }
30
+ }
31
+ const UUID_RE = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
32
+ /**
33
+ * Resolve a Pipeline by identity.
34
+ *
35
+ * The identity may be:
36
+ * - a UUID — fetched directly via `pipeline.info`.
37
+ * - a name — looked up via `GET /pipelines?eq[name]=<name>`. Pipeline
38
+ * names are not unique platform-wide, so a name lookup may return
39
+ * zero, one, or many matches.
40
+ *
41
+ * The name path uses a raw `HerokuApiClient` because the route
42
+ * registry's `pipeline.list` doesn't accept query params, and we don't
43
+ * want to bake those params into the routes-generated client (where
44
+ * they would leak across unrelated calls).
45
+ *
46
+ * Errors:
47
+ * - throws `PipelineNotFoundError` when no pipeline matches the name.
48
+ * - throws `PipelineAmbiguousError` when more than one does. Callers
49
+ * that want to prompt the user should catch this and inspect
50
+ * `error.matches`.
51
+ */
52
+ export async function resolvePipeline(ctx, identity, options = {}) {
53
+ options.signal?.throwIfAborted();
54
+ if (UUID_RE.test(identity)) {
55
+ return ctx.platform.pipeline.info(identity);
56
+ }
57
+ const apiClient = new HerokuApiClient({
58
+ ...options.clientOptions,
59
+ service: 'platform',
60
+ });
61
+ const response = await apiClient.get('/pipelines', {
62
+ searchParams: { 'eq[name]': identity },
63
+ });
64
+ const matches = (await response.json());
65
+ if (matches.length === 0) {
66
+ throw new PipelineNotFoundError(identity);
67
+ }
68
+ if (matches.length > 1) {
69
+ throw new PipelineAmbiguousError(identity, matches);
70
+ }
71
+ return matches[0];
72
+ }
73
+ export const pipelineExtensions = extendResource('platform', 'pipeline', ctx => ({
74
+ resolve: (identity, options) => resolvePipeline(ctx, identity, options),
75
+ }));
@@ -0,0 +1,21 @@
1
+ import type { PipelinePromotion, PipelinePromotionCreateOpts, PipelinePromotionTarget } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ export type ReleaseStreamContext = {
4
+ stream: ReadableStream<Uint8Array>;
5
+ target: PipelinePromotionTarget;
6
+ };
7
+ export type PromotePipelineOptions = {
8
+ intervalMs?: number;
9
+ onReleaseStream?: (context: ReleaseStreamContext) => Promise<void> | void;
10
+ releaseStreamMaxAttempts?: number;
11
+ signal?: AbortSignal;
12
+ timeoutMs?: number;
13
+ };
14
+ export type PromotePipelineResult = {
15
+ promotion: PipelinePromotion;
16
+ targets: PipelinePromotionTarget[];
17
+ };
18
+ export declare function promotePipeline(ctx: Pick<ResourceCtx, 'platform'>, body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions): Promise<PromotePipelineResult>;
19
+ export declare const pipelinePromotionExtensions: import("../../../index.js").ResourceExtension<"platform", "pipelinePromotion", {
20
+ promote: (body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions) => Promise<PromotePipelineResult>;
21
+ }>;
@@ -0,0 +1,109 @@
1
+ import { HerokuApiClient } from '@heroku/heroku-fetch';
2
+ import createDebug from 'debug';
3
+ import { extendResource } from '../../../core/extend-resource.js';
4
+ import { wait } from '../../../utils/wait.js';
5
+ const debug = createDebug('heroku:sdk:resources:pipeline-promotion');
6
+ const DEFAULT_INTERVAL_MS = 1000;
7
+ const DEFAULT_RELEASE_STREAM_MAX_ATTEMPTS = 100;
8
+ export async function promotePipeline(ctx, body, options = {}) {
9
+ const { intervalMs = DEFAULT_INTERVAL_MS, onReleaseStream, releaseStreamMaxAttempts = DEFAULT_RELEASE_STREAM_MAX_ATTEMPTS, signal, timeoutMs, } = options;
10
+ debug('promote source=%s targets=%d', body.source?.app?.id ?? '<unknown>', body.targets?.length ?? 0);
11
+ const promotion = await ctx.platform.pipelinePromotion.create(body);
12
+ if (!promotion.id) {
13
+ throw new Error('Pipeline promotion response did not include an id');
14
+ }
15
+ debug('promote created promotion=%s intervalMs=%d timeoutMs=%s', promotion.id, intervalMs, timeoutMs ?? '<none>');
16
+ const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
17
+ const streamRunner = onReleaseStream
18
+ ? createReleaseStreamRunner(ctx, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal)
19
+ : undefined;
20
+ let poll = 0;
21
+ while (true) {
22
+ signal?.throwIfAborted();
23
+ poll++;
24
+ // eslint-disable-next-line no-await-in-loop
25
+ const targets = await ctx.platform.pipelinePromotionTarget.list(promotion.id);
26
+ debug('promote poll=%d promotion=%s statuses=%o', poll, promotion.id, targets.map(target => target.status));
27
+ if (targets.every(target => target.status !== 'pending')) {
28
+ debug('promote terminal promotion=%s polls=%d', promotion.id, poll);
29
+ return { promotion, targets };
30
+ }
31
+ // eslint-disable-next-line no-await-in-loop
32
+ await streamRunner?.(targets);
33
+ if (deadline !== undefined && Date.now() >= deadline) {
34
+ throw new Error(`Pipeline promotion ${promotion.id} did not reach a terminal state within ${timeoutMs}ms`);
35
+ }
36
+ // eslint-disable-next-line no-await-in-loop
37
+ await wait(intervalMs, signal);
38
+ }
39
+ }
40
+ /**
41
+ * Returns a function that, on first call where `targets` is a single
42
+ * promotion target with `release.id` and `app.id`, fetches the release's
43
+ * output stream and hands it to `onReleaseStream`. Subsequent calls are
44
+ * no-ops; the stream is one-shot per promotion.
45
+ */
46
+ function createReleaseStreamRunner(ctx, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal) {
47
+ let handled = false;
48
+ return async (targets) => {
49
+ if (handled || targets.length !== 1)
50
+ return;
51
+ const target = targets[0];
52
+ if (!target.release?.id || !target.app?.id)
53
+ return;
54
+ handled = true;
55
+ 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);
57
+ if (!release.output_stream_url) {
58
+ debug('promote release-stream skipped reason=no-output-stream-url');
59
+ return;
60
+ }
61
+ debug('promote release-stream fetching url=%s maxAttempts=%d', release.output_stream_url, releaseStreamMaxAttempts);
62
+ const stream = await fetchReleaseOutput(release.output_stream_url, releaseStreamMaxAttempts, intervalMs, signal);
63
+ await onReleaseStream({ stream, target });
64
+ };
65
+ }
66
+ export const pipelinePromotionExtensions = extendResource('platform', 'pipelinePromotion', ctx => ({
67
+ promote: (body, options) => promotePipeline(ctx, body, options),
68
+ }));
69
+ async function fetchReleaseOutput(url, maxAttempts, intervalMs, signal) {
70
+ const parsed = new URL(url);
71
+ const path = `${parsed.pathname}${parsed.search}`;
72
+ // The release output URL points at a third-party stream host (e.g. busl).
73
+ // Use a custom-service client so the platform prefixUrl and bearer token
74
+ // don't leak across origins.
75
+ const buslClient = new HerokuApiClient({
76
+ baseUrl: parsed.origin,
77
+ service: 'custom',
78
+ token: '',
79
+ });
80
+ let attempt = 0;
81
+ while (true) {
82
+ signal?.throwIfAborted();
83
+ attempt++;
84
+ let response;
85
+ try {
86
+ // eslint-disable-next-line no-await-in-loop
87
+ response = await buslClient.stream(path);
88
+ }
89
+ catch {
90
+ // Treat HTTP errors thrown by the api-client as a retryable miss.
91
+ response = undefined;
92
+ }
93
+ if (response?.ok && response.body) {
94
+ debug('release-stream attempt=%d status=%d ok stream=true', attempt, response.status);
95
+ return response.body;
96
+ }
97
+ debug('release-stream attempt=%d status=%s ok=false', attempt, response?.status ?? '<error>');
98
+ if (response?.body) {
99
+ // Drain so the connection can be reused.
100
+ // eslint-disable-next-line no-await-in-loop
101
+ await response.body.cancel().catch(() => { });
102
+ }
103
+ if (attempt >= maxAttempts) {
104
+ throw new Error('stream release output not available');
105
+ }
106
+ // eslint-disable-next-line no-await-in-loop
107
+ await wait(intervalMs, signal);
108
+ }
109
+ }
@@ -0,0 +1,27 @@
1
+ import type { PipelineCoupling, TeamApp } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../core/extend-resource.js';
3
+ export type PipelineWarning = {
4
+ limit: number;
5
+ pipelineId: string;
6
+ type: 'apps_truncated';
7
+ };
8
+ export type ListPipelineAppsOptions = {
9
+ onWarning?: (warning: PipelineWarning) => void;
10
+ signal?: AbortSignal;
11
+ };
12
+ export type AppWithPipelineCoupling = TeamApp & {
13
+ pipelineCoupling: PipelineCoupling;
14
+ };
15
+ /**
16
+ * List the apps coupled to a pipeline, each decorated with its
17
+ * `PipelineCoupling`.
18
+ *
19
+ * Couplings missing an app id are dropped before issuing the bulk
20
+ * filter call. If the pipeline has more than 1000 couplings, an
21
+ * `onWarning` handler must be supplied to opt into a truncated
22
+ * result; otherwise this throws.
23
+ */
24
+ export declare function listPipelineApps(ctx: Pick<ResourceCtx, 'platform'>, pipelineId: string, options?: ListPipelineAppsOptions): Promise<AppWithPipelineCoupling[]>;
25
+ export declare const pipelineCouplingExtensions: import("../../index.js").ResourceExtension<"platform", "pipelineCoupling", {
26
+ listApps: (pipelineId: string, options?: ListPipelineAppsOptions) => Promise<AppWithPipelineCoupling[]>;
27
+ }>;
@@ -0,0 +1,55 @@
1
+ import createDebug from 'debug';
2
+ import { extendResource } from '../../core/extend-resource.js';
3
+ const debug = createDebug('heroku:sdk:resources:pipeline-coupling');
4
+ const APPS_FILTER_LIMIT = 1000;
5
+ /**
6
+ * List the apps coupled to a pipeline, each decorated with its
7
+ * `PipelineCoupling`.
8
+ *
9
+ * Couplings missing an app id are dropped before issuing the bulk
10
+ * filter call. If the pipeline has more than 1000 couplings, an
11
+ * `onWarning` handler must be supplied to opt into a truncated
12
+ * result; otherwise this throws.
13
+ */
14
+ export async function listPipelineApps(ctx, pipelineId, options = {}) {
15
+ options.signal?.throwIfAborted();
16
+ const allCouplings = await ctx.platform.pipelineCoupling.listByPipeline(pipelineId);
17
+ // Drop malformed couplings (no app id) before issuing the bulk filter call.
18
+ const couplings = allCouplings.filter(coupling => coupling.app?.id);
19
+ const skipped = allCouplings.length - couplings.length;
20
+ if (skipped > 0) {
21
+ debug('listPipelineApps pipeline=%s skipped=%d couplings without app.id', pipelineId, skipped);
22
+ }
23
+ if (couplings.length === 0) {
24
+ return [];
25
+ }
26
+ let couplingsToResolve = couplings;
27
+ if (couplings.length > APPS_FILTER_LIMIT) {
28
+ if (!options.onWarning) {
29
+ throw new Error(`Pipeline ${pipelineId} has more than ${APPS_FILTER_LIMIT} apps. `
30
+ + 'Pass an onWarning handler to opt into a truncated result.');
31
+ }
32
+ debug('listPipelineApps pipeline=%s truncating couplings=%d limit=%d', pipelineId, couplings.length, APPS_FILTER_LIMIT);
33
+ options.onWarning({ limit: APPS_FILTER_LIMIT, pipelineId, type: 'apps_truncated' });
34
+ couplingsToResolve = couplings.slice(0, APPS_FILTER_LIMIT);
35
+ }
36
+ options.signal?.throwIfAborted();
37
+ const ids = couplingsToResolve.map(coupling => coupling.app.id);
38
+ const apps = await ctx.platform
39
+ .withHeaders({ Range: `id ..; max=${APPS_FILTER_LIMIT};` })
40
+ .filterApps.apps({ in: { id: ids } });
41
+ if (apps.length !== ids.length) {
42
+ debug('listPipelineApps pipeline=%s requested=%d returned=%d', pipelineId, ids.length, apps.length);
43
+ }
44
+ const couplingByAppId = new Map();
45
+ for (const coupling of couplingsToResolve) {
46
+ couplingByAppId.set(coupling.app.id, coupling);
47
+ }
48
+ return apps.map(app => ({
49
+ ...app,
50
+ pipelineCoupling: couplingByAppId.get(app.id),
51
+ }));
52
+ }
53
+ export const pipelineCouplingExtensions = extendResource('platform', 'pipelineCoupling', ctx => ({
54
+ listApps: (pipelineId, options) => listPipelineApps(ctx, pipelineId, options),
55
+ }));
@@ -0,0 +1,21 @@
1
+ import type { PipelinePromotion, PipelinePromotionCreateOpts, PipelinePromotionTarget } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../core/extend-resource.js';
3
+ export type ReleaseStreamContext = {
4
+ stream: ReadableStream<Uint8Array>;
5
+ target: PipelinePromotionTarget;
6
+ };
7
+ export type PromotePipelineOptions = {
8
+ intervalMs?: number;
9
+ onReleaseStream?: (context: ReleaseStreamContext) => Promise<void> | void;
10
+ releaseStreamMaxAttempts?: number;
11
+ signal?: AbortSignal;
12
+ timeoutMs?: number;
13
+ };
14
+ export type PromotePipelineResult = {
15
+ promotion: PipelinePromotion;
16
+ targets: PipelinePromotionTarget[];
17
+ };
18
+ export declare function promotePipeline(ctx: Pick<ResourceCtx, 'platform'>, body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions): Promise<PromotePipelineResult>;
19
+ export declare const pipelinePromotionExtensions: import("../../index.js").ResourceExtension<"platform", "pipelinePromotion", {
20
+ promote: (body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions) => Promise<PromotePipelineResult>;
21
+ }>;