@redocly/cli 1.11.0 → 1.12.1

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.
@@ -29,8 +29,8 @@ describe('handlePush()', () => {
29
29
 
30
30
  beforeEach(() => {
31
31
  remotes.getDefaultBranch.mockResolvedValueOnce('test-default-branch');
32
- remotes.upsert.mockResolvedValueOnce({ id: 'test-remote-id' });
33
- remotes.push.mockResolvedValueOnce({ branchName: 'uploaded-to-branch' });
32
+ remotes.upsert.mockResolvedValueOnce({ id: 'test-remote-id', mountPath: 'test-mount-path' });
33
+ remotes.push.mockResolvedValueOnce({ branchName: 'uploaded-to-branch', id: 'test-id' });
34
34
 
35
35
  jest.spyOn(fs, 'createReadStream').mockReturnValue('stream' as any);
36
36
 
@@ -118,6 +118,44 @@ describe('handlePush()', () => {
118
118
  );
119
119
  });
120
120
 
121
+ it('should return push id', async () => {
122
+ const mockConfig = { apis: {} } as any;
123
+ process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
124
+
125
+ fsStatSyncSpy.mockReturnValueOnce({
126
+ isDirectory() {
127
+ return false;
128
+ },
129
+ } as any);
130
+
131
+ pathResolveSpy.mockImplementationOnce((p) => p);
132
+ pathRelativeSpy.mockImplementationOnce((_, p) => p);
133
+ pathDirnameSpy.mockImplementation((_: string) => '.');
134
+
135
+ const result = await handlePush(
136
+ {
137
+ domain: 'test-domain',
138
+ 'mount-path': 'test-mount-path',
139
+ organization: 'test-org',
140
+ project: 'test-project',
141
+ branch: 'test-branch',
142
+ namespace: 'test-namespace',
143
+ repository: 'test-repository',
144
+ 'commit-sha': 'test-commit-sha',
145
+ 'commit-url': 'test-commit-url',
146
+ 'default-branch': 'test-branch',
147
+ 'created-at': 'test-created-at',
148
+ author: 'TestAuthor <test-author@mail.com>',
149
+ message: 'Test message',
150
+ files: ['test-file'],
151
+ 'max-execution-time': 10,
152
+ },
153
+ mockConfig
154
+ );
155
+
156
+ expect(result).toEqual({ pushId: 'test-id' });
157
+ });
158
+
121
159
  it('should collect files from directory and preserve file structure', async () => {
122
160
  const mockConfig = { apis: {} } as any;
123
161
  process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
@@ -0,0 +1,62 @@
1
+ import { retryUntilConditionMet } from '../utils';
2
+
3
+ jest.mock('@redocly/openapi-core', () => ({
4
+ pause: jest.requireActual('@redocly/openapi-core').pause,
5
+ }));
6
+
7
+ describe('retryUntilConditionMet()', () => {
8
+ it('should retry until condition meet and return result', async () => {
9
+ const operation = jest
10
+ .fn()
11
+ .mockResolvedValueOnce({ status: 'pending' })
12
+ .mockResolvedValueOnce({ status: 'pending' })
13
+ .mockResolvedValueOnce({ status: 'done' });
14
+
15
+ const data = await retryUntilConditionMet({
16
+ operation,
17
+ condition: (result: any) => result?.status === 'done',
18
+ retryIntervalMs: 100,
19
+ retryTimeoutMs: 1000,
20
+ });
21
+
22
+ expect(data).toEqual({ status: 'done' });
23
+ });
24
+
25
+ it('should throw error if condition not meet for desired timeout', async () => {
26
+ const operation = jest.fn().mockResolvedValue({ status: 'pending' });
27
+
28
+ await expect(
29
+ retryUntilConditionMet({
30
+ operation,
31
+ condition: (result: any) => result?.status === 'done',
32
+ retryIntervalMs: 100,
33
+ retryTimeoutMs: 1000,
34
+ })
35
+ ).rejects.toThrow('Timeout exceeded');
36
+ });
37
+
38
+ it('should call "onConditionNotMet" and "onRetry" callbacks', async () => {
39
+ const operation = jest
40
+ .fn()
41
+ .mockResolvedValueOnce({ status: 'pending' })
42
+ .mockResolvedValueOnce({ status: 'pending' })
43
+ .mockResolvedValueOnce({ status: 'done' });
44
+
45
+ const onConditionNotMet = jest.fn();
46
+ const onRetry = jest.fn();
47
+
48
+ const data = await retryUntilConditionMet({
49
+ operation,
50
+ condition: (result: any) => result?.status === 'done',
51
+ retryIntervalMs: 100,
52
+ retryTimeoutMs: 1000,
53
+ onConditionNotMet,
54
+ onRetry,
55
+ });
56
+
57
+ expect(data).toEqual({ status: 'done' });
58
+
59
+ expect(onConditionNotMet).toHaveBeenCalledTimes(2);
60
+ expect(onRetry).toHaveBeenCalledTimes(2);
61
+ });
62
+ });
@@ -1,15 +1,20 @@
1
1
  import * as colors from 'colorette';
2
- import { Config } from '@redocly/openapi-core';
2
+ import type { Config, OutputFormat } from '@redocly/openapi-core';
3
+
3
4
  import { exitWithError, printExecutionTime } from '../../utils/miscellaneous';
4
5
  import { Spinner } from '../../utils/spinner';
5
6
  import { DeploymentError } from '../utils';
6
- import { yellow } from 'colorette';
7
7
  import { ReuniteApiClient, getApiKeys, getDomain } from '../api';
8
8
  import { capitalize } from '../../utils/js-utils';
9
+ import type {
10
+ DeploymentStatus,
11
+ DeploymentStatusResponse,
12
+ PushResponse,
13
+ ScorecardItem,
14
+ } from '../api/types';
15
+ import { retryUntilConditionMet } from './utils';
9
16
 
10
- import type { DeploymentStatus, PushResponse, ScorecardItem } from '../api/types';
11
-
12
- const INTERVAL = 5000;
17
+ const RETRY_INTERVAL_MS = 5000; // 5 sec
13
18
 
14
19
  export type PushStatusOptions = {
15
20
  organization: string;
@@ -17,12 +22,25 @@ export type PushStatusOptions = {
17
22
  pushId: string;
18
23
  domain?: string;
19
24
  config?: string;
20
- format?: 'stylish' | 'json';
25
+ format?: Extract<OutputFormat, 'stylish'>;
21
26
  wait?: boolean;
22
- 'max-execution-time': number;
27
+ 'max-execution-time'?: number; // in seconds
28
+ 'retry-interval'?: number; // in seconds
29
+ 'start-time'?: number; // in milliseconds
30
+ 'continue-on-deploy-failures'?: boolean;
31
+ onRetry?: (lasSummary: PushStatusSummary) => void;
23
32
  };
24
33
 
25
- export async function handlePushStatus(argv: PushStatusOptions, config: Config) {
34
+ export interface PushStatusSummary {
35
+ preview: DeploymentStatusResponse;
36
+ production: DeploymentStatusResponse | null;
37
+ commit: PushResponse['commit'];
38
+ }
39
+
40
+ export async function handlePushStatus(
41
+ argv: PushStatusOptions,
42
+ config: Config
43
+ ): Promise<PushStatusSummary | undefined> {
26
44
  const startedAt = performance.now();
27
45
  const spinner = new Spinner();
28
46
 
@@ -31,123 +49,198 @@ export async function handlePushStatus(argv: PushStatusOptions, config: Config)
31
49
  const orgId = organization || config.organization;
32
50
 
33
51
  if (!orgId) {
34
- return exitWithError(
52
+ exitWithError(
35
53
  `No organization provided, please use --organization option or specify the 'organization' field in the config file.`
36
54
  );
55
+ return;
37
56
  }
38
57
 
39
58
  const domain = argv.domain || getDomain();
40
-
41
- if (!domain) {
42
- return exitWithError(
43
- `No domain provided, please use --domain option or environment variable REDOCLY_DOMAIN.`
44
- );
45
- }
46
-
47
- const maxExecutionTime = argv['max-execution-time'] || 600;
59
+ const maxExecutionTime = argv['max-execution-time'] || 1200; // 20 min
60
+ const retryIntervalMs = argv['retry-interval']
61
+ ? argv['retry-interval'] * 1000
62
+ : RETRY_INTERVAL_MS;
63
+ const startTime = argv['start-time'] || Date.now();
64
+ const retryTimeoutMs = maxExecutionTime * 1000;
65
+ const continueOnDeployFailures = argv['continue-on-deploy-failures'] || false;
48
66
 
49
67
  try {
50
68
  const apiKey = getApiKeys(domain);
51
69
  const client = new ReuniteApiClient(domain, apiKey);
52
70
 
53
- if (wait) {
54
- const push = await waitForDeployment(client, 'preview');
71
+ let pushResponse: PushResponse;
55
72
 
56
- if (push.isMainBranch && push.status.preview.deploy.status === 'success') {
57
- await waitForDeployment(client, 'production');
58
- }
73
+ pushResponse = await retryUntilConditionMet({
74
+ operation: () =>
75
+ client.remotes.getPush({
76
+ organizationId: orgId,
77
+ projectId,
78
+ pushId,
79
+ }),
80
+ condition: wait
81
+ ? // Keep retrying if status is "pending" or "running" (returning false, so the operation will be retried)
82
+ (result) => !['pending', 'running'].includes(result.status['preview'].deploy.status)
83
+ : null,
84
+ onConditionNotMet: (lastResult) => {
85
+ displayDeploymentAndBuildStatus({
86
+ status: lastResult.status['preview'].deploy.status,
87
+ url: lastResult.status['preview'].deploy.url,
88
+ spinner,
89
+ buildType: 'preview',
90
+ continueOnDeployFailures,
91
+ wait,
92
+ });
93
+ },
94
+ onRetry: (lastResult) => {
95
+ if (argv.onRetry) {
96
+ argv.onRetry({
97
+ preview: lastResult.status.preview,
98
+ production: lastResult.isMainBranch ? lastResult.status.production : null,
99
+ commit: lastResult.commit,
100
+ });
101
+ }
102
+ },
103
+ startTime,
104
+ retryTimeoutMs,
105
+ retryIntervalMs,
106
+ });
59
107
 
60
- printPushStatusInfo();
61
- return;
62
- }
108
+ printPushStatus({
109
+ buildType: 'preview',
110
+ spinner,
111
+ wait,
112
+ push: pushResponse,
113
+ continueOnDeployFailures,
114
+ });
115
+ printScorecard(pushResponse.status.preview.scorecard);
63
116
 
64
- const pushPreview = await getAndPrintPushStatus(client, 'preview');
65
- printScorecard(pushPreview.status.preview.scorecard);
117
+ const shouldWaitForProdDeployment =
118
+ pushResponse.isMainBranch &&
119
+ (wait ? pushResponse.status.preview.deploy.status === 'success' : true);
66
120
 
67
- if (pushPreview.isMainBranch) {
68
- await getAndPrintPushStatus(client, 'production');
69
- printScorecard(pushPreview.status.production.scorecard);
121
+ if (shouldWaitForProdDeployment) {
122
+ pushResponse = await retryUntilConditionMet({
123
+ operation: () =>
124
+ client.remotes.getPush({
125
+ organizationId: orgId,
126
+ projectId,
127
+ pushId,
128
+ }),
129
+ condition: wait
130
+ ? // Keep retrying if status is "pending" or "running" (returning false, so the operation will be retried)
131
+ (result) => !['pending', 'running'].includes(result.status['production'].deploy.status)
132
+ : null,
133
+ onConditionNotMet: (lastResult) => {
134
+ displayDeploymentAndBuildStatus({
135
+ status: lastResult.status['production'].deploy.status,
136
+ url: lastResult.status['production'].deploy.url,
137
+ spinner,
138
+ buildType: 'production',
139
+ continueOnDeployFailures,
140
+ wait,
141
+ });
142
+ },
143
+ onRetry: (lastResult) => {
144
+ if (argv.onRetry) {
145
+ argv.onRetry({
146
+ preview: lastResult.status.preview,
147
+ production: lastResult.isMainBranch ? lastResult.status.production : null,
148
+ commit: lastResult.commit,
149
+ });
150
+ }
151
+ },
152
+ startTime,
153
+ retryTimeoutMs,
154
+ retryIntervalMs,
155
+ });
156
+ }
157
+
158
+ if (pushResponse.isMainBranch) {
159
+ printPushStatus({
160
+ buildType: 'production',
161
+ spinner,
162
+ wait,
163
+ push: pushResponse,
164
+ continueOnDeployFailures,
165
+ });
166
+ printScorecard(pushResponse.status.production.scorecard);
70
167
  }
168
+ printPushStatusInfo({ orgId, projectId, pushId, startedAt });
71
169
 
72
- printPushStatusInfo();
170
+ const summary: PushStatusSummary = {
171
+ preview: pushResponse.status.preview,
172
+ production: pushResponse.isMainBranch ? pushResponse.status.production : null,
173
+ commit: pushResponse.commit,
174
+ };
175
+
176
+ return summary;
73
177
  } catch (err) {
178
+ spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly.
179
+
74
180
  const message =
75
181
  err instanceof DeploymentError
76
182
  ? err.message
77
183
  : `✗ Failed to get push status. Reason: ${err.message}\n`;
78
184
  exitWithError(message);
185
+ return;
186
+ } finally {
187
+ spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly.
79
188
  }
189
+ }
80
190
 
81
- function printPushStatusInfo() {
82
- process.stderr.write(
83
- `\nProcessed push-status for ${colors.yellow(orgId!)}, ${colors.yellow(
84
- projectId
85
- )} and pushID ${colors.yellow(pushId)}.\n`
86
- );
87
- printExecutionTime('push-status', startedAt, 'Finished');
88
- }
89
-
90
- async function waitForDeployment(
91
- client: ReuniteApiClient,
92
- buildType: 'preview' | 'production'
93
- ): Promise<PushResponse> {
94
- return new Promise((resolve, reject) => {
95
- if (performance.now() - startedAt > maxExecutionTime * 1000) {
96
- spinner.stop();
97
- reject(new Error(`Time limit exceeded.`));
98
- }
99
-
100
- getAndPrintPushStatus(client, buildType)
101
- .then((push) => {
102
- if (!['pending', 'running'].includes(push.status[buildType].deploy.status)) {
103
- printScorecard(push.status[buildType].scorecard);
104
- resolve(push);
105
- return;
106
- }
191
+ function printPushStatusInfo({
192
+ orgId,
193
+ projectId,
194
+ pushId,
195
+ startedAt,
196
+ }: {
197
+ orgId: string;
198
+ projectId: string;
199
+ pushId: string;
200
+ startedAt: number;
201
+ }) {
202
+ process.stderr.write(
203
+ `\nProcessed push-status for ${colors.yellow(orgId!)}, ${colors.yellow(
204
+ projectId
205
+ )} and pushID ${colors.yellow(pushId)}.\n`
206
+ );
207
+ printExecutionTime('push-status', startedAt, 'Finished');
208
+ }
107
209
 
108
- setTimeout(async () => {
109
- try {
110
- const pushResponse = await waitForDeployment(client, buildType);
111
- resolve(pushResponse);
112
- } catch (e) {
113
- reject(e);
114
- }
115
- }, INTERVAL);
116
- })
117
- .catch(reject);
118
- });
210
+ function printPushStatus({
211
+ buildType,
212
+ spinner,
213
+ push,
214
+ continueOnDeployFailures,
215
+ }: {
216
+ buildType: 'preview' | 'production';
217
+ spinner: Spinner;
218
+ wait?: boolean;
219
+ push?: PushResponse | null;
220
+ continueOnDeployFailures: boolean;
221
+ }) {
222
+ if (!push) {
223
+ return;
119
224
  }
120
-
121
- async function getAndPrintPushStatus(
122
- client: ReuniteApiClient,
123
- buildType: 'preview' | 'production'
124
- ) {
125
- const push = await client.remotes.getPush({
126
- organizationId: orgId!,
127
- projectId,
128
- pushId,
225
+ if (push.isOutdated || !push.hasChanges) {
226
+ process.stderr.write(
227
+ colors.yellow(
228
+ `Files not added to your project. Reason: ${push.isOutdated ? 'outdated' : 'no changes'}.\n`
229
+ )
230
+ );
231
+ } else {
232
+ displayDeploymentAndBuildStatus({
233
+ status: push.status[buildType].deploy.status,
234
+ url: push.status[buildType].deploy.url,
235
+ buildType,
236
+ spinner,
237
+ continueOnDeployFailures,
129
238
  });
130
-
131
- if (push.isOutdated || !push.hasChanges) {
132
- process.stderr.write(
133
- yellow(`Files not uploaded. Reason: ${push.isOutdated ? 'outdated' : 'no changes'}.\n`)
134
- );
135
- } else {
136
- displayDeploymentAndBuildStatus({
137
- status: push.status[buildType].deploy.status,
138
- previewUrl: push.status[buildType].deploy.url,
139
- buildType,
140
- spinner,
141
- wait,
142
- });
143
- }
144
-
145
- return push;
146
239
  }
147
240
  }
148
241
 
149
- function printScorecard(scorecard: ScorecardItem[]) {
150
- if (!scorecard.length) {
242
+ function printScorecard(scorecard?: ScorecardItem[]) {
243
+ if (!scorecard || scorecard.length === 0) {
151
244
  return;
152
245
  }
153
246
  process.stdout.write(`\n${colors.magenta('Scorecard')}:`);
@@ -163,42 +256,71 @@ function printScorecard(scorecard: ScorecardItem[]) {
163
256
 
164
257
  function displayDeploymentAndBuildStatus({
165
258
  status,
166
- previewUrl,
259
+ url,
167
260
  spinner,
168
261
  buildType,
262
+ continueOnDeployFailures,
169
263
  wait,
170
264
  }: {
171
265
  status: DeploymentStatus;
172
- previewUrl: string | null;
266
+ url: string | null;
173
267
  spinner: Spinner;
174
268
  buildType: 'preview' | 'production';
269
+ continueOnDeployFailures: boolean;
175
270
  wait?: boolean;
176
271
  }) {
272
+ const message = getMessage({ status, url, buildType, wait });
273
+
274
+ if (status === 'failed' && !continueOnDeployFailures) {
275
+ spinner.stop();
276
+ throw new DeploymentError(message);
277
+ }
278
+
279
+ if (wait && (status === 'pending' || status === 'running')) {
280
+ return spinner.start(message);
281
+ }
282
+
283
+ spinner.stop();
284
+ return process.stdout.write(message);
285
+ }
286
+
287
+ function getMessage({
288
+ status,
289
+ url,
290
+ buildType,
291
+ wait,
292
+ }: {
293
+ status: DeploymentStatus;
294
+ url: string | null;
295
+ buildType: 'preview' | 'production';
296
+ wait?: boolean;
297
+ }): string {
177
298
  switch (status) {
299
+ case 'skipped':
300
+ return `${colors.yellow(`Skipped ${buildType}`)}\n`;
301
+
302
+ case 'pending': {
303
+ const message = `${colors.yellow(`Pending ${buildType}`)}`;
304
+ return wait ? message : `Status: ${message}\n`;
305
+ }
306
+ case 'running': {
307
+ const message = `${colors.yellow(`Running ${buildType}`)}`;
308
+ return wait ? message : `Status: ${message}\n`;
309
+ }
178
310
  case 'success':
179
- spinner.stop();
180
- return process.stdout.write(
181
- `${colors.green(`🚀 ${capitalize(buildType)} deploy success.`)}\n${colors.magenta(
182
- `${capitalize(buildType)} URL`
183
- )}: ${colors.cyan(previewUrl!)}\n`
184
- );
311
+ return `${colors.green(`🚀 ${capitalize(buildType)} deploy success.`)}\n${colors.magenta(
312
+ `${capitalize(buildType)} URL`
313
+ )}: ${colors.cyan(url || 'No URL yet.')}\n`;
314
+
185
315
  case 'failed':
186
- spinner.stop();
187
- throw new DeploymentError(
188
- `${colors.red(`❌ ${capitalize(buildType)} deploy fail.`)}\n${colors.magenta(
189
- `${capitalize(buildType)} URL`
190
- )}: ${colors.cyan(previewUrl!)}`
191
- );
192
- case 'pending':
193
- return wait
194
- ? spinner.start(`${colors.yellow(`Pending ${buildType}`)}`)
195
- : process.stdout.write(`Status: ${colors.yellow(`Pending ${buildType}`)}\n`);
196
- case 'skipped':
197
- spinner.stop();
198
- return process.stdout.write(`${colors.yellow(`Skipped ${buildType}`)}\n`);
199
- case 'running':
200
- return wait
201
- ? spinner.start(`${colors.yellow(`Running ${buildType}`)}`)
202
- : process.stdout.write(`Status: ${colors.yellow(`Running ${buildType}`)}\n`);
316
+ return `${colors.red(`❌ ${capitalize(buildType)} deploy fail.`)}\n${colors.magenta(
317
+ `${capitalize(buildType)} URL`
318
+ )}: ${colors.cyan(url || 'No URL yet.')}`;
319
+
320
+ default: {
321
+ const message = `${colors.yellow(`No status yet for ${buildType} deploy`)}`;
322
+
323
+ return wait ? message : `Status: ${message}\n`;
324
+ }
203
325
  }
204
326
  }
@@ -1,9 +1,12 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import { Config, slash } from '@redocly/openapi-core';
4
- import { exitWithError, HandledError, printExecutionTime } from '../../utils/miscellaneous';
3
+ import { slash } from '@redocly/openapi-core';
5
4
  import { green, yellow } from 'colorette';
6
5
  import pluralize = require('pluralize');
6
+
7
+ import type { OutputFormat, Config } from '@redocly/openapi-core';
8
+
9
+ import { exitWithError, HandledError, printExecutionTime } from '../../utils/miscellaneous';
7
10
  import { handlePushStatus } from './push-status';
8
11
  import { ReuniteApiClient, getDomain, getApiKeys } from '../api';
9
12
 
@@ -29,13 +32,20 @@ export type PushOptions = {
29
32
  config?: string;
30
33
  'wait-for-deployment'?: boolean;
31
34
  'max-execution-time': number;
35
+ 'continue-on-deploy-failures'?: boolean;
32
36
  verbose?: boolean;
37
+ format?: Extract<OutputFormat, 'stylish'>;
33
38
  };
34
39
 
35
40
  type FileToUpload = { name: string; path: string };
36
41
 
37
- export async function handlePush(argv: PushOptions, config: Config) {
38
- const startedAt = performance.now();
42
+ export async function handlePush(
43
+ argv: PushOptions,
44
+ config: Config
45
+ ): Promise<{ pushId: string } | void> {
46
+ const startedAt = performance.now(); // for printing execution time
47
+ const startTime = Date.now(); // for push-status command
48
+
39
49
  const { organization, project: projectId, 'mount-path': mountPath, verbose } = argv;
40
50
 
41
51
  const orgId = organization || config.organization;
@@ -111,8 +121,8 @@ export async function handlePush(argv: PushOptions, config: Config) {
111
121
  filesToUpload.forEach((f) => {
112
122
  process.stderr.write(green(`✓ ${f.name}\n`));
113
123
  });
114
- process.stdout.write('\n');
115
124
 
125
+ process.stdout.write('\n');
116
126
  process.stdout.write(`Push ID: ${id}\n`);
117
127
 
118
128
  if (waitForDeployment) {
@@ -126,6 +136,8 @@ export async function handlePush(argv: PushOptions, config: Config) {
126
136
  wait: true,
127
137
  domain,
128
138
  'max-execution-time': maxExecutionTime,
139
+ 'start-time': startTime,
140
+ 'continue-on-deploy-failures': argv['continue-on-deploy-failures'],
129
141
  },
130
142
  config
131
143
  );
@@ -139,6 +151,10 @@ export async function handlePush(argv: PushOptions, config: Config) {
139
151
  filesToUpload.length
140
152
  )} uploaded to organization ${orgId}, project ${projectId}. Push ID: ${id}.`
141
153
  );
154
+
155
+ return {
156
+ pushId: id,
157
+ };
142
158
  } catch (err) {
143
159
  const message =
144
160
  err instanceof HandledError ? '' : `✗ File upload failed. Reason: ${err.message}`;
@@ -0,0 +1,52 @@
1
+ import { pause } from '@redocly/openapi-core';
2
+
3
+ /**
4
+ * This function retries an operation until a condition is met or a timeout is exceeded.
5
+ * If the condition is not met within the timeout, an error is thrown.
6
+ * @operation The operation to retry.
7
+ * @condition The condition to check after each operation result. Return false to continue retrying. Return true to stop retrying.
8
+ * If not provided, the first result will be returned.
9
+ * @param onConditionNotMet Will be called with the last result right after checking condition and before timeout and retrying.
10
+ * @param onRetry Will be called right before retrying operation with the last result before retrying.
11
+ * @param startTime The start time of the operation. Default is the current time.
12
+ * @param retryTimeoutMs The maximum time to retry the operation. Default is 10 minutes.
13
+ * @param retryIntervalMs The interval between retries. Default is 5 seconds.
14
+ */
15
+ export async function retryUntilConditionMet<T>({
16
+ operation,
17
+ condition,
18
+ onConditionNotMet,
19
+ onRetry,
20
+ startTime = Date.now(),
21
+ retryTimeoutMs = 600000, // 10 min
22
+ retryIntervalMs = 5000, // 5 sec
23
+ }: {
24
+ operation: () => Promise<T>;
25
+ condition?: ((result: T) => boolean) | null;
26
+ onConditionNotMet?: (lastResult: T) => void;
27
+ onRetry?: (lastResult: T) => void | Promise<void>;
28
+ startTime?: number;
29
+ retryTimeoutMs?: number;
30
+ retryIntervalMs?: number;
31
+ }): Promise<T> {
32
+ async function attempt(): Promise<T> {
33
+ const result = await operation();
34
+
35
+ if (!condition) {
36
+ return result;
37
+ }
38
+
39
+ if (condition(result)) {
40
+ return result;
41
+ } else if (Date.now() - startTime > retryTimeoutMs) {
42
+ throw new Error('Timeout exceeded');
43
+ } else {
44
+ onConditionNotMet?.(result);
45
+ await pause(retryIntervalMs);
46
+ await onRetry?.(result);
47
+ return attempt();
48
+ }
49
+ }
50
+
51
+ return attempt();
52
+ }