@prisma/compute-sdk 0.1.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.
- package/README.md +638 -0
- package/dist/api-client.d.ts +181 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +193 -0
- package/dist/api-client.js.map +1 -0
- package/dist/archive.d.ts +11 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +80 -0
- package/dist/archive.js.map +1 -0
- package/dist/build-strategy.d.ts +34 -0
- package/dist/build-strategy.d.ts.map +1 -0
- package/dist/build-strategy.js +33 -0
- package/dist/build-strategy.js.map +1 -0
- package/dist/bun-build.d.ts +14 -0
- package/dist/bun-build.d.ts.map +1 -0
- package/dist/bun-build.js +128 -0
- package/dist/bun-build.js.map +1 -0
- package/dist/callbacks.d.ts +44 -0
- package/dist/callbacks.d.ts.map +1 -0
- package/dist/callbacks.js +2 -0
- package/dist/callbacks.js.map +1 -0
- package/dist/compute-client.d.ts +136 -0
- package/dist/compute-client.d.ts.map +1 -0
- package/dist/compute-client.js +562 -0
- package/dist/compute-client.js.map +1 -0
- package/dist/errors.d.ts +101 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +56 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/polling.d.ts +22 -0
- package/dist/polling.d.ts.map +1 -0
- package/dist/polling.js +64 -0
- package/dist/polling.js.map +1 -0
- package/dist/types.d.ts +46 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +17 -0
- package/dist/types.js.map +1 -0
- package/package.json +43 -0
- package/src/api-client.ts +330 -0
- package/src/archive.ts +111 -0
- package/src/build-strategy.ts +66 -0
- package/src/bun-build.ts +184 -0
- package/src/callbacks.ts +50 -0
- package/src/compute-client.ts +985 -0
- package/src/errors.ts +148 -0
- package/src/index.ts +75 -0
- package/src/polling.ts +108 -0
- package/src/types.ts +65 -0
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
import type { ManagementApiClient } from "@prisma/management-api-sdk";
|
|
2
|
+
import { type InferOk, Result } from "better-result";
|
|
3
|
+
import {
|
|
4
|
+
InternalApiClient,
|
|
5
|
+
readUploadUrl,
|
|
6
|
+
toDeploymentUrl,
|
|
7
|
+
} from "./api-client.ts";
|
|
8
|
+
import { createArchive } from "./archive.ts";
|
|
9
|
+
import type { BuildStrategy } from "./build-strategy.ts";
|
|
10
|
+
import type {
|
|
11
|
+
DeployInteraction,
|
|
12
|
+
DeployProgress,
|
|
13
|
+
DestroyServiceProgress,
|
|
14
|
+
DestroyVersionInteraction,
|
|
15
|
+
DestroyVersionProgress,
|
|
16
|
+
UpdateEnvProgress,
|
|
17
|
+
} from "./callbacks.ts";
|
|
18
|
+
import type { ComputeError } from "./errors.ts";
|
|
19
|
+
import {
|
|
20
|
+
ApiError,
|
|
21
|
+
ArtifactError,
|
|
22
|
+
BuildError,
|
|
23
|
+
CancelledError,
|
|
24
|
+
DestroyAggregateError,
|
|
25
|
+
MissingArgumentError,
|
|
26
|
+
NoExistingVersionError,
|
|
27
|
+
} from "./errors.ts";
|
|
28
|
+
import { pollVersionStatus } from "./polling.ts";
|
|
29
|
+
import type {
|
|
30
|
+
PortMapping,
|
|
31
|
+
ProjectInfo,
|
|
32
|
+
ResolvedConfig,
|
|
33
|
+
ServiceDetail,
|
|
34
|
+
ServiceInfo,
|
|
35
|
+
VersionDetail,
|
|
36
|
+
VersionInfo,
|
|
37
|
+
} from "./types.ts";
|
|
38
|
+
import { REGIONS } from "./types.ts";
|
|
39
|
+
|
|
40
|
+
type VersionDetailData = InferOk<
|
|
41
|
+
Awaited<ReturnType<InternalApiClient["getVersion"]>>
|
|
42
|
+
>;
|
|
43
|
+
|
|
44
|
+
export interface DeployOptions {
|
|
45
|
+
strategy: BuildStrategy;
|
|
46
|
+
projectId?: string;
|
|
47
|
+
serviceId?: string;
|
|
48
|
+
serviceName?: string;
|
|
49
|
+
region?: string;
|
|
50
|
+
envVars?: Record<string, string>;
|
|
51
|
+
portMapping?: PortMapping;
|
|
52
|
+
timeoutSeconds?: number;
|
|
53
|
+
pollIntervalMs?: number;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
interaction?: DeployInteraction;
|
|
56
|
+
progress?: DeployProgress;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UpdateEnvOptions {
|
|
60
|
+
projectId?: string;
|
|
61
|
+
serviceId?: string;
|
|
62
|
+
envVars?: Record<string, string>;
|
|
63
|
+
portMapping?: PortMapping;
|
|
64
|
+
timeoutSeconds?: number;
|
|
65
|
+
pollIntervalMs?: number;
|
|
66
|
+
signal?: AbortSignal;
|
|
67
|
+
interaction?: DeployInteraction;
|
|
68
|
+
progress?: UpdateEnvProgress;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DestroyVersionOptions {
|
|
72
|
+
versionId?: string;
|
|
73
|
+
serviceId?: string;
|
|
74
|
+
projectId?: string;
|
|
75
|
+
timeoutSeconds?: number;
|
|
76
|
+
pollIntervalMs?: number;
|
|
77
|
+
signal?: AbortSignal;
|
|
78
|
+
interaction?: DestroyVersionInteraction;
|
|
79
|
+
progress?: DestroyVersionProgress;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface DestroyServiceOptions {
|
|
83
|
+
serviceId: string;
|
|
84
|
+
keepService?: boolean;
|
|
85
|
+
timeoutSeconds?: number;
|
|
86
|
+
pollIntervalMs?: number;
|
|
87
|
+
signal?: AbortSignal;
|
|
88
|
+
progress?: DestroyServiceProgress;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ListServicesOptions {
|
|
92
|
+
projectId: string;
|
|
93
|
+
signal?: AbortSignal;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CreateServiceOptions {
|
|
97
|
+
projectId: string;
|
|
98
|
+
serviceName: string;
|
|
99
|
+
region: string;
|
|
100
|
+
signal?: AbortSignal;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ShowServiceOptions {
|
|
104
|
+
serviceId: string;
|
|
105
|
+
signal?: AbortSignal;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface DeleteServiceOptions {
|
|
109
|
+
serviceId: string;
|
|
110
|
+
signal?: AbortSignal;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ListProjectsOptions {
|
|
114
|
+
signal?: AbortSignal;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ListVersionsOptions {
|
|
118
|
+
serviceId: string;
|
|
119
|
+
signal?: AbortSignal;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ShowVersionOptions {
|
|
123
|
+
versionId: string;
|
|
124
|
+
signal?: AbortSignal;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface StartVersionOptions {
|
|
128
|
+
versionId: string;
|
|
129
|
+
signal?: AbortSignal;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface StopVersionOptions {
|
|
133
|
+
versionId: string;
|
|
134
|
+
signal?: AbortSignal;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface DeleteVersionOptions {
|
|
138
|
+
versionId: string;
|
|
139
|
+
signal?: AbortSignal;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface DeployResult {
|
|
143
|
+
projectId: string;
|
|
144
|
+
serviceId: string;
|
|
145
|
+
serviceName: string;
|
|
146
|
+
region: string;
|
|
147
|
+
versionId: string;
|
|
148
|
+
deploymentUrl: string;
|
|
149
|
+
resolvedConfig: ResolvedConfig;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface UpdateEnvResult {
|
|
153
|
+
projectId: string;
|
|
154
|
+
serviceId: string;
|
|
155
|
+
versionId: string;
|
|
156
|
+
deploymentUrl: string;
|
|
157
|
+
resolvedConfig: ResolvedConfig;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface DestroyVersionResult {
|
|
161
|
+
versionId: string;
|
|
162
|
+
previousStatus: string;
|
|
163
|
+
stopped: boolean;
|
|
164
|
+
deleted: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface DestroyServiceResult {
|
|
168
|
+
serviceId: string;
|
|
169
|
+
deletedVersionIds: string[];
|
|
170
|
+
serviceDeleted: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const DEFAULT_TIMEOUT_SECONDS = 120;
|
|
174
|
+
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
175
|
+
|
|
176
|
+
export class ComputeClient {
|
|
177
|
+
readonly #api: InternalApiClient;
|
|
178
|
+
|
|
179
|
+
constructor(managementApiClient: ManagementApiClient) {
|
|
180
|
+
this.#api = new InternalApiClient(managementApiClient);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async deploy(
|
|
184
|
+
options: DeployOptions,
|
|
185
|
+
): Promise<Result<DeployResult, ComputeError>> {
|
|
186
|
+
const self = this;
|
|
187
|
+
return Result.gen(async function* () {
|
|
188
|
+
yield* self.#checkAborted(options.signal);
|
|
189
|
+
|
|
190
|
+
const target = yield* Result.await(self.#resolveDeployTarget(options));
|
|
191
|
+
|
|
192
|
+
options.progress?.onBuildStart?.();
|
|
193
|
+
const artifact = yield* Result.await(
|
|
194
|
+
Result.tryPromise({
|
|
195
|
+
try: () => options.strategy.execute(),
|
|
196
|
+
catch: (e) =>
|
|
197
|
+
new BuildError({
|
|
198
|
+
message: e instanceof Error ? e.message : String(e),
|
|
199
|
+
}),
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
options.progress?.onBuildComplete?.(artifact);
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
yield* self.#checkAborted(options.signal);
|
|
206
|
+
|
|
207
|
+
options.progress?.onArchiveCreating?.();
|
|
208
|
+
const archiveBytes = yield* Result.await(
|
|
209
|
+
Result.tryPromise({
|
|
210
|
+
try: () => createArchive(artifact.directory, artifact.entrypoint),
|
|
211
|
+
catch: (e) =>
|
|
212
|
+
new ArtifactError({
|
|
213
|
+
message:
|
|
214
|
+
e instanceof Error
|
|
215
|
+
? e.message
|
|
216
|
+
: `Archive creation failed: ${String(e)}`,
|
|
217
|
+
cause: e,
|
|
218
|
+
}),
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
options.progress?.onArchiveReady?.(archiveBytes.byteLength);
|
|
222
|
+
|
|
223
|
+
yield* self.#checkAborted(options.signal);
|
|
224
|
+
|
|
225
|
+
const createResponse = yield* Result.await(
|
|
226
|
+
self.#api.createServiceVersion(
|
|
227
|
+
target.serviceId,
|
|
228
|
+
{
|
|
229
|
+
envVars: options.envVars,
|
|
230
|
+
portMapping: options.portMapping,
|
|
231
|
+
},
|
|
232
|
+
options.signal,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
options.progress?.onVersionCreated?.(createResponse.id);
|
|
236
|
+
|
|
237
|
+
const uploadUrl = readUploadUrl(createResponse);
|
|
238
|
+
if (!uploadUrl) {
|
|
239
|
+
return Result.err(
|
|
240
|
+
new ArtifactError({
|
|
241
|
+
message: "Version creation did not return an upload URL",
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
yield* self.#checkAborted(options.signal);
|
|
247
|
+
|
|
248
|
+
options.progress?.onUploadStart?.();
|
|
249
|
+
yield* Result.await(self.#uploadArtifact(uploadUrl, archiveBytes));
|
|
250
|
+
options.progress?.onUploadComplete?.();
|
|
251
|
+
|
|
252
|
+
yield* self.#checkAborted(options.signal);
|
|
253
|
+
|
|
254
|
+
options.progress?.onStartRequested?.();
|
|
255
|
+
yield* Result.await(
|
|
256
|
+
self.#api.startVersion(createResponse.id, options.signal),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const pollResult = yield* Result.await(
|
|
260
|
+
pollVersionStatus(self.#api, createResponse.id, {
|
|
261
|
+
targetStatus: "running",
|
|
262
|
+
timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS,
|
|
263
|
+
pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
264
|
+
signal: options.signal,
|
|
265
|
+
onStatusChange: options.progress?.onStatusChange,
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const deploymentUrl = toDeploymentUrl(pollResult.previewDomain);
|
|
270
|
+
options.progress?.onRunning?.(deploymentUrl);
|
|
271
|
+
|
|
272
|
+
return Result.ok({
|
|
273
|
+
projectId: target.projectId,
|
|
274
|
+
serviceId: target.serviceId,
|
|
275
|
+
serviceName: target.serviceName,
|
|
276
|
+
region: target.region,
|
|
277
|
+
versionId: createResponse.id,
|
|
278
|
+
deploymentUrl,
|
|
279
|
+
resolvedConfig: {
|
|
280
|
+
projectId: target.projectId,
|
|
281
|
+
serviceId: target.serviceId,
|
|
282
|
+
serviceName: target.serviceName,
|
|
283
|
+
region: target.region,
|
|
284
|
+
portMapping: options.portMapping,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
} finally {
|
|
288
|
+
await artifact.cleanup?.();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async updateEnv(
|
|
294
|
+
options: UpdateEnvOptions,
|
|
295
|
+
): Promise<Result<UpdateEnvResult, ComputeError>> {
|
|
296
|
+
const self = this;
|
|
297
|
+
return Result.gen(async function* () {
|
|
298
|
+
yield* self.#checkAborted(options.signal);
|
|
299
|
+
|
|
300
|
+
const target = yield* Result.await(self.#resolveDeployTarget(options));
|
|
301
|
+
|
|
302
|
+
const versions = yield* Result.await(
|
|
303
|
+
self.#api.listServiceVersions(target.serviceId, options.signal),
|
|
304
|
+
);
|
|
305
|
+
if (versions.length === 0) {
|
|
306
|
+
return Result.err(
|
|
307
|
+
new NoExistingVersionError({ serviceId: target.serviceId }),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
yield* self.#checkAborted(options.signal);
|
|
312
|
+
|
|
313
|
+
const createResponse = yield* Result.await(
|
|
314
|
+
self.#api.createServiceVersion(
|
|
315
|
+
target.serviceId,
|
|
316
|
+
{
|
|
317
|
+
envVars: options.envVars,
|
|
318
|
+
portMapping: options.portMapping,
|
|
319
|
+
skipCodeUpload: true,
|
|
320
|
+
},
|
|
321
|
+
options.signal,
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
options.progress?.onVersionCreated?.(createResponse.id);
|
|
325
|
+
|
|
326
|
+
yield* self.#checkAborted(options.signal);
|
|
327
|
+
|
|
328
|
+
options.progress?.onStartRequested?.();
|
|
329
|
+
yield* Result.await(
|
|
330
|
+
self.#api.startVersion(createResponse.id, options.signal),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const pollResult = yield* Result.await(
|
|
334
|
+
pollVersionStatus(self.#api, createResponse.id, {
|
|
335
|
+
targetStatus: "running",
|
|
336
|
+
timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS,
|
|
337
|
+
pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
338
|
+
signal: options.signal,
|
|
339
|
+
onStatusChange: options.progress?.onStatusChange,
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const deploymentUrl = toDeploymentUrl(pollResult.previewDomain);
|
|
344
|
+
options.progress?.onRunning?.(deploymentUrl);
|
|
345
|
+
|
|
346
|
+
return Result.ok({
|
|
347
|
+
projectId: target.projectId,
|
|
348
|
+
serviceId: target.serviceId,
|
|
349
|
+
versionId: createResponse.id,
|
|
350
|
+
deploymentUrl,
|
|
351
|
+
resolvedConfig: {
|
|
352
|
+
projectId: target.projectId,
|
|
353
|
+
serviceId: target.serviceId,
|
|
354
|
+
serviceName: target.serviceName,
|
|
355
|
+
region: target.region,
|
|
356
|
+
portMapping: options.portMapping,
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async destroyVersion(
|
|
363
|
+
options: DestroyVersionOptions,
|
|
364
|
+
): Promise<Result<DestroyVersionResult, ComputeError>> {
|
|
365
|
+
const self = this;
|
|
366
|
+
return Result.gen(async function* () {
|
|
367
|
+
yield* self.#checkAborted(options.signal);
|
|
368
|
+
|
|
369
|
+
let versionId = options.versionId;
|
|
370
|
+
let versionDetailsCache: Map<string, VersionDetailData> | undefined;
|
|
371
|
+
|
|
372
|
+
if (!versionId) {
|
|
373
|
+
const serviceId = options.serviceId;
|
|
374
|
+
if (!serviceId) {
|
|
375
|
+
return Result.err(new MissingArgumentError({ field: "versionId" }));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const versions = yield* Result.await(
|
|
379
|
+
self.#api.listServiceVersions(serviceId, options.signal),
|
|
380
|
+
);
|
|
381
|
+
const versionDetailResults = await Promise.all(
|
|
382
|
+
versions.map((v) => self.#api.getVersion(v.id, options.signal)),
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const details: VersionDetailData[] = [];
|
|
386
|
+
for (const result of versionDetailResults) {
|
|
387
|
+
if (result.isErr()) return result;
|
|
388
|
+
details.push(result.value);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
versionDetailsCache = new Map(details.map((v) => [v.id, v]));
|
|
392
|
+
|
|
393
|
+
const versionInfos = details
|
|
394
|
+
.map(
|
|
395
|
+
(v): VersionInfo => ({
|
|
396
|
+
id: v.id,
|
|
397
|
+
status: v.status,
|
|
398
|
+
createdAt: v.createdAt,
|
|
399
|
+
previewDomain: v.previewDomain,
|
|
400
|
+
}),
|
|
401
|
+
)
|
|
402
|
+
.sort(versionSortComparator);
|
|
403
|
+
|
|
404
|
+
if (versionInfos.length === 0) {
|
|
405
|
+
return Result.err(new MissingArgumentError({ field: "versionId" }));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (!options.interaction?.selectVersion) {
|
|
409
|
+
return Result.err(new MissingArgumentError({ field: "versionId" }));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
versionId = await options.interaction.selectVersion(versionInfos);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const version =
|
|
416
|
+
versionDetailsCache?.get(versionId) ??
|
|
417
|
+
(yield* Result.await(self.#api.getVersion(versionId, options.signal)));
|
|
418
|
+
|
|
419
|
+
const previousStatus = version.status ?? "unknown";
|
|
420
|
+
const shouldStop =
|
|
421
|
+
version.status === "running" || version.status === "provisioning";
|
|
422
|
+
let stopped = false;
|
|
423
|
+
|
|
424
|
+
if (shouldStop) {
|
|
425
|
+
options.progress?.onStopRequested?.(versionId);
|
|
426
|
+
yield* Result.await(self.#api.stopVersion(versionId, options.signal));
|
|
427
|
+
|
|
428
|
+
yield* Result.await(
|
|
429
|
+
pollVersionStatus(self.#api, versionId, {
|
|
430
|
+
targetStatus: "stopped",
|
|
431
|
+
timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS,
|
|
432
|
+
pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
433
|
+
signal: options.signal,
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
stopped = true;
|
|
437
|
+
options.progress?.onStopped?.(versionId);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
yield* Result.await(self.#api.deleteVersion(versionId, options.signal));
|
|
441
|
+
options.progress?.onDeleted?.(versionId);
|
|
442
|
+
|
|
443
|
+
return Result.ok({
|
|
444
|
+
versionId,
|
|
445
|
+
previousStatus,
|
|
446
|
+
stopped,
|
|
447
|
+
deleted: true,
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async destroyService(
|
|
453
|
+
options: DestroyServiceOptions,
|
|
454
|
+
): Promise<Result<DestroyServiceResult, ComputeError>> {
|
|
455
|
+
const self = this;
|
|
456
|
+
return Result.gen(async function* () {
|
|
457
|
+
yield* self.#checkAborted(options.signal);
|
|
458
|
+
|
|
459
|
+
const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
460
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
461
|
+
|
|
462
|
+
const versions = yield* Result.await(
|
|
463
|
+
self.#api.listServiceVersions(options.serviceId, options.signal),
|
|
464
|
+
);
|
|
465
|
+
const versionDetailResults = await Promise.all(
|
|
466
|
+
versions.map((v) => self.#api.getVersion(v.id, options.signal)),
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const versionDetails: VersionDetailData[] = [];
|
|
470
|
+
for (const result of versionDetailResults) {
|
|
471
|
+
if (result.isErr()) return result;
|
|
472
|
+
versionDetails.push(result.value);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const activeVersions = versionDetails.filter(
|
|
476
|
+
(v) => v.status === "running" || v.status === "provisioning",
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
const succeededIds: string[] = [];
|
|
480
|
+
const failures: Array<{ versionId: string; error: ComputeError }> = [];
|
|
481
|
+
|
|
482
|
+
if (activeVersions.length > 0) {
|
|
483
|
+
const activeIds = activeVersions.map((v) => v.id);
|
|
484
|
+
options.progress?.onStoppingVersions?.(activeIds);
|
|
485
|
+
|
|
486
|
+
const stopResults = await Promise.all(
|
|
487
|
+
activeVersions.map((v) =>
|
|
488
|
+
self.#api.stopVersion(v.id, options.signal),
|
|
489
|
+
),
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
const stoppedCandidates: typeof activeVersions = [];
|
|
493
|
+
for (const [i, result] of stopResults.entries()) {
|
|
494
|
+
// biome-ignore lint/style/noNonNullAssertion: both lists have the same length
|
|
495
|
+
const activeVersion = activeVersions[i]!;
|
|
496
|
+
if (result.isErr()) {
|
|
497
|
+
failures.push({
|
|
498
|
+
versionId: activeVersion.id,
|
|
499
|
+
error: result.error,
|
|
500
|
+
});
|
|
501
|
+
} else {
|
|
502
|
+
stoppedCandidates.push(activeVersion);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (stoppedCandidates.length > 0) {
|
|
507
|
+
const pollResults = await Promise.all(
|
|
508
|
+
stoppedCandidates.map((v) =>
|
|
509
|
+
pollVersionStatus(self.#api, v.id, {
|
|
510
|
+
targetStatus: "stopped",
|
|
511
|
+
timeoutSeconds,
|
|
512
|
+
pollIntervalMs,
|
|
513
|
+
signal: options.signal,
|
|
514
|
+
}),
|
|
515
|
+
),
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
for (const [i, result] of pollResults.entries()) {
|
|
519
|
+
// biome-ignore lint/style/noNonNullAssertion: both lists have the same length
|
|
520
|
+
const stoppedVersion = stoppedCandidates[i]!;
|
|
521
|
+
if (result.isErr()) {
|
|
522
|
+
failures.push({
|
|
523
|
+
versionId: stoppedVersion.id,
|
|
524
|
+
error: result.error,
|
|
525
|
+
});
|
|
526
|
+
} else {
|
|
527
|
+
options.progress?.onVersionStopped?.(stoppedVersion.id);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (failures.length > 0) {
|
|
533
|
+
return Result.err(
|
|
534
|
+
new DestroyAggregateError({
|
|
535
|
+
succeededVersionIds: succeededIds,
|
|
536
|
+
failures,
|
|
537
|
+
serviceDeleted: false,
|
|
538
|
+
}),
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
options.progress?.onAllVersionsStopped?.();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (versions.length > 0) {
|
|
546
|
+
const versionIds = versions.map((v) => v.id);
|
|
547
|
+
options.progress?.onDeletingVersions?.(versionIds);
|
|
548
|
+
|
|
549
|
+
const deleteResults = await Promise.all(
|
|
550
|
+
versions.map((v) => self.#api.deleteVersion(v.id, options.signal)),
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
for (const [i, result] of deleteResults.entries()) {
|
|
554
|
+
// biome-ignore lint/style/noNonNullAssertion: both lists have the same length
|
|
555
|
+
const version = versions[i]!;
|
|
556
|
+
if (result.isErr()) {
|
|
557
|
+
failures.push({
|
|
558
|
+
versionId: version.id,
|
|
559
|
+
error: result.error,
|
|
560
|
+
});
|
|
561
|
+
} else {
|
|
562
|
+
succeededIds.push(version.id);
|
|
563
|
+
options.progress?.onVersionDeleted?.(version.id);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (failures.length > 0) {
|
|
568
|
+
return Result.err(
|
|
569
|
+
new DestroyAggregateError({
|
|
570
|
+
succeededVersionIds: succeededIds,
|
|
571
|
+
failures,
|
|
572
|
+
serviceDeleted: false,
|
|
573
|
+
}),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
options.progress?.onAllVersionsDeleted?.();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
let serviceDeleted = false;
|
|
581
|
+
if (!options.keepService) {
|
|
582
|
+
yield* Result.await(
|
|
583
|
+
self.#api.deleteService(options.serviceId, options.signal),
|
|
584
|
+
);
|
|
585
|
+
serviceDeleted = true;
|
|
586
|
+
options.progress?.onServiceDeleted?.(options.serviceId);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return Result.ok({
|
|
590
|
+
serviceId: options.serviceId,
|
|
591
|
+
deletedVersionIds: succeededIds,
|
|
592
|
+
serviceDeleted,
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async listProjects(
|
|
598
|
+
options: ListProjectsOptions = {},
|
|
599
|
+
): Promise<Result<ProjectInfo[], ComputeError>> {
|
|
600
|
+
const self = this;
|
|
601
|
+
return Result.gen(async function* () {
|
|
602
|
+
yield* self.#checkAborted(options.signal);
|
|
603
|
+
const projects = yield* Result.await(
|
|
604
|
+
self.#api.listProjects(options.signal),
|
|
605
|
+
);
|
|
606
|
+
return Result.ok(
|
|
607
|
+
projects.map(
|
|
608
|
+
(p): ProjectInfo => ({
|
|
609
|
+
id: p.id,
|
|
610
|
+
name: p.name,
|
|
611
|
+
defaultRegion: p.defaultRegion ?? undefined,
|
|
612
|
+
}),
|
|
613
|
+
),
|
|
614
|
+
);
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async listServices(
|
|
619
|
+
options: ListServicesOptions,
|
|
620
|
+
): Promise<Result<ServiceInfo[], ComputeError>> {
|
|
621
|
+
const self = this;
|
|
622
|
+
return Result.gen(async function* () {
|
|
623
|
+
yield* self.#checkAborted(options.signal);
|
|
624
|
+
const services = yield* Result.await(
|
|
625
|
+
self.#api.listProjectServices(options.projectId, options.signal),
|
|
626
|
+
);
|
|
627
|
+
return Result.ok(
|
|
628
|
+
services.map(
|
|
629
|
+
(s): ServiceInfo => ({
|
|
630
|
+
id: s.id,
|
|
631
|
+
name: s.name,
|
|
632
|
+
region: s.region.id,
|
|
633
|
+
projectId: s.projectId,
|
|
634
|
+
}),
|
|
635
|
+
),
|
|
636
|
+
);
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async createService(
|
|
641
|
+
options: CreateServiceOptions,
|
|
642
|
+
): Promise<Result<ServiceInfo, ComputeError>> {
|
|
643
|
+
const self = this;
|
|
644
|
+
return Result.gen(async function* () {
|
|
645
|
+
yield* self.#checkAborted(options.signal);
|
|
646
|
+
const response = yield* Result.await(
|
|
647
|
+
self.#api.createProjectService(
|
|
648
|
+
options.projectId,
|
|
649
|
+
{
|
|
650
|
+
displayName: options.serviceName,
|
|
651
|
+
regionId: options.region,
|
|
652
|
+
},
|
|
653
|
+
options.signal,
|
|
654
|
+
),
|
|
655
|
+
);
|
|
656
|
+
return Result.ok({
|
|
657
|
+
id: response.id,
|
|
658
|
+
name: response.name,
|
|
659
|
+
region: response.region.id,
|
|
660
|
+
projectId: options.projectId,
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async showService(
|
|
666
|
+
options: ShowServiceOptions,
|
|
667
|
+
): Promise<Result<ServiceDetail, ComputeError>> {
|
|
668
|
+
const self = this;
|
|
669
|
+
return Result.gen(async function* () {
|
|
670
|
+
yield* self.#checkAborted(options.signal);
|
|
671
|
+
const s = yield* Result.await(
|
|
672
|
+
self.#api.getService(options.serviceId, options.signal),
|
|
673
|
+
);
|
|
674
|
+
return Result.ok({
|
|
675
|
+
id: s.id,
|
|
676
|
+
name: s.name,
|
|
677
|
+
region: s.region.id,
|
|
678
|
+
projectId: s.projectId,
|
|
679
|
+
latestVersionId: s.latestVersionId,
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async deleteService(
|
|
685
|
+
options: DeleteServiceOptions,
|
|
686
|
+
): Promise<Result<void, ComputeError>> {
|
|
687
|
+
const self = this;
|
|
688
|
+
return Result.gen(async function* () {
|
|
689
|
+
yield* self.#checkAborted(options.signal);
|
|
690
|
+
yield* Result.await(
|
|
691
|
+
self.#api.deleteService(options.serviceId, options.signal),
|
|
692
|
+
);
|
|
693
|
+
return Result.ok();
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async listVersions(
|
|
698
|
+
options: ListVersionsOptions,
|
|
699
|
+
): Promise<Result<VersionInfo[], ComputeError>> {
|
|
700
|
+
const self = this;
|
|
701
|
+
return Result.gen(async function* () {
|
|
702
|
+
yield* self.#checkAborted(options.signal);
|
|
703
|
+
const versions = yield* Result.await(
|
|
704
|
+
self.#api.listServiceVersions(options.serviceId, options.signal),
|
|
705
|
+
);
|
|
706
|
+
const detailResults = await Promise.all(
|
|
707
|
+
versions.map((v) => self.#api.getVersion(v.id, options.signal)),
|
|
708
|
+
);
|
|
709
|
+
const details: VersionDetailData[] = [];
|
|
710
|
+
for (const result of detailResults) {
|
|
711
|
+
if (result.isErr()) {
|
|
712
|
+
// listServiceVersions returns versions that have previously been deleted
|
|
713
|
+
// and can't be fetched using getVersion so we need to filter those out.
|
|
714
|
+
if (ApiError.is(result.error) && result.error.statusCode === 404)
|
|
715
|
+
continue;
|
|
716
|
+
return result;
|
|
717
|
+
}
|
|
718
|
+
details.push(result.value);
|
|
719
|
+
}
|
|
720
|
+
return Result.ok(
|
|
721
|
+
details.map(
|
|
722
|
+
(v): VersionInfo => ({
|
|
723
|
+
id: v.id,
|
|
724
|
+
status: v.status,
|
|
725
|
+
createdAt: v.createdAt,
|
|
726
|
+
previewDomain: v.previewDomain,
|
|
727
|
+
}),
|
|
728
|
+
),
|
|
729
|
+
);
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async showVersion(
|
|
734
|
+
options: ShowVersionOptions,
|
|
735
|
+
): Promise<Result<VersionDetail, ComputeError>> {
|
|
736
|
+
const self = this;
|
|
737
|
+
return Result.gen(async function* () {
|
|
738
|
+
yield* self.#checkAborted(options.signal);
|
|
739
|
+
const v = yield* Result.await(
|
|
740
|
+
self.#api.getVersion(options.versionId, options.signal),
|
|
741
|
+
);
|
|
742
|
+
return Result.ok({
|
|
743
|
+
id: v.id,
|
|
744
|
+
status: v.status,
|
|
745
|
+
createdAt: v.createdAt,
|
|
746
|
+
previewDomain: v.previewDomain,
|
|
747
|
+
envVars: v.envVars ?? undefined,
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async startVersion(
|
|
753
|
+
options: StartVersionOptions,
|
|
754
|
+
): Promise<Result<void, ComputeError>> {
|
|
755
|
+
const self = this;
|
|
756
|
+
return Result.gen(async function* () {
|
|
757
|
+
yield* self.#checkAborted(options.signal);
|
|
758
|
+
yield* Result.await(
|
|
759
|
+
self.#api.startVersion(options.versionId, options.signal),
|
|
760
|
+
);
|
|
761
|
+
return Result.ok();
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async stopVersion(
|
|
766
|
+
options: StopVersionOptions,
|
|
767
|
+
): Promise<Result<void, ComputeError>> {
|
|
768
|
+
const self = this;
|
|
769
|
+
return Result.gen(async function* () {
|
|
770
|
+
yield* self.#checkAborted(options.signal);
|
|
771
|
+
yield* Result.await(
|
|
772
|
+
self.#api.stopVersion(options.versionId, options.signal),
|
|
773
|
+
);
|
|
774
|
+
return Result.ok();
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async deleteVersion(
|
|
779
|
+
options: DeleteVersionOptions,
|
|
780
|
+
): Promise<Result<void, ComputeError>> {
|
|
781
|
+
const self = this;
|
|
782
|
+
return Result.gen(async function* () {
|
|
783
|
+
yield* self.#checkAborted(options.signal);
|
|
784
|
+
yield* Result.await(
|
|
785
|
+
self.#api.deleteVersion(options.versionId, options.signal),
|
|
786
|
+
);
|
|
787
|
+
return Result.ok();
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
#checkAborted(signal?: AbortSignal): Result<void, CancelledError> {
|
|
792
|
+
if (signal?.aborted) {
|
|
793
|
+
return Result.err(new CancelledError());
|
|
794
|
+
}
|
|
795
|
+
return Result.ok(undefined);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async #resolveDeployTarget(options: {
|
|
799
|
+
projectId?: string;
|
|
800
|
+
serviceId?: string;
|
|
801
|
+
serviceName?: string;
|
|
802
|
+
region?: string;
|
|
803
|
+
signal?: AbortSignal;
|
|
804
|
+
interaction?: DeployInteraction;
|
|
805
|
+
}): Promise<
|
|
806
|
+
Result<
|
|
807
|
+
{
|
|
808
|
+
projectId: string;
|
|
809
|
+
serviceId: string;
|
|
810
|
+
serviceName: string;
|
|
811
|
+
region: string;
|
|
812
|
+
},
|
|
813
|
+
CancelledError | MissingArgumentError | ComputeError
|
|
814
|
+
>
|
|
815
|
+
> {
|
|
816
|
+
const self = this;
|
|
817
|
+
return Result.gen(async function* () {
|
|
818
|
+
if (options.serviceId) {
|
|
819
|
+
const service = yield* Result.await(
|
|
820
|
+
self.#api.getService(options.serviceId, options.signal),
|
|
821
|
+
);
|
|
822
|
+
const projectId = options.projectId ?? service.projectId;
|
|
823
|
+
if (!projectId) {
|
|
824
|
+
return Result.err(new MissingArgumentError({ field: "projectId" }));
|
|
825
|
+
}
|
|
826
|
+
return Result.ok({
|
|
827
|
+
projectId,
|
|
828
|
+
serviceId: service.id,
|
|
829
|
+
serviceName: service.name ?? service.id,
|
|
830
|
+
region: service.region.id,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
let projectId = options.projectId;
|
|
835
|
+
if (!projectId) {
|
|
836
|
+
const projects = yield* Result.await(
|
|
837
|
+
self.#api.listProjects(options.signal),
|
|
838
|
+
);
|
|
839
|
+
const projectInfos = projects.map(
|
|
840
|
+
(p): ProjectInfo => ({
|
|
841
|
+
id: p.id,
|
|
842
|
+
name: p.name,
|
|
843
|
+
defaultRegion: p.defaultRegion ?? undefined,
|
|
844
|
+
}),
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
if (!options.interaction?.selectProject) {
|
|
848
|
+
return Result.err(new MissingArgumentError({ field: "projectId" }));
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
projectId = await options.interaction.selectProject(projectInfos);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (options.serviceName && options.region) {
|
|
855
|
+
const response = yield* Result.await(
|
|
856
|
+
self.#api.createProjectService(
|
|
857
|
+
projectId,
|
|
858
|
+
{
|
|
859
|
+
displayName: options.serviceName,
|
|
860
|
+
regionId: options.region,
|
|
861
|
+
},
|
|
862
|
+
options.signal,
|
|
863
|
+
),
|
|
864
|
+
);
|
|
865
|
+
return Result.ok({
|
|
866
|
+
projectId,
|
|
867
|
+
serviceId: response.id,
|
|
868
|
+
serviceName: response.name,
|
|
869
|
+
region: response.region.id,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const services = yield* Result.await(
|
|
874
|
+
self.#api.listProjectServices(projectId, options.signal),
|
|
875
|
+
);
|
|
876
|
+
const serviceInfos = services.map(
|
|
877
|
+
(s): ServiceInfo => ({
|
|
878
|
+
id: s.id,
|
|
879
|
+
name: s.name,
|
|
880
|
+
region: s.region.id,
|
|
881
|
+
projectId: s.projectId,
|
|
882
|
+
}),
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
if (!options.interaction?.selectService) {
|
|
886
|
+
return Result.err(new MissingArgumentError({ field: "serviceId" }));
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const selectedServiceId =
|
|
890
|
+
await options.interaction.selectService(serviceInfos);
|
|
891
|
+
|
|
892
|
+
if (selectedServiceId !== null) {
|
|
893
|
+
const service = services.find((s) => s.id === selectedServiceId);
|
|
894
|
+
if (!service) {
|
|
895
|
+
throw new Error("selectService returned service ID not in the list");
|
|
896
|
+
}
|
|
897
|
+
return Result.ok({
|
|
898
|
+
projectId,
|
|
899
|
+
serviceId: selectedServiceId,
|
|
900
|
+
serviceName: service.name,
|
|
901
|
+
region: service.region.id,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
let serviceName = options.serviceName;
|
|
906
|
+
if (!serviceName) {
|
|
907
|
+
if (!options.interaction?.provideServiceName) {
|
|
908
|
+
return Result.err(new MissingArgumentError({ field: "serviceName" }));
|
|
909
|
+
}
|
|
910
|
+
serviceName = await options.interaction.provideServiceName();
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
let region = options.region;
|
|
914
|
+
if (!region) {
|
|
915
|
+
if (!options.interaction?.selectRegion) {
|
|
916
|
+
return Result.err(new MissingArgumentError({ field: "region" }));
|
|
917
|
+
}
|
|
918
|
+
region = await options.interaction.selectRegion(REGIONS);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const response = yield* Result.await(
|
|
922
|
+
self.#api.createProjectService(
|
|
923
|
+
projectId,
|
|
924
|
+
{
|
|
925
|
+
displayName: serviceName,
|
|
926
|
+
regionId: region,
|
|
927
|
+
},
|
|
928
|
+
options.signal,
|
|
929
|
+
),
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
return Result.ok({
|
|
933
|
+
projectId,
|
|
934
|
+
serviceId: response.id,
|
|
935
|
+
serviceName: response.name,
|
|
936
|
+
region: response.region.id,
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async #uploadArtifact(
|
|
942
|
+
url: string,
|
|
943
|
+
body: Uint8Array,
|
|
944
|
+
): Promise<Result<void, ArtifactError>> {
|
|
945
|
+
return Result.tryPromise({
|
|
946
|
+
try: async () => {
|
|
947
|
+
const res = await fetch(url, {
|
|
948
|
+
method: "PUT",
|
|
949
|
+
headers: { "content-type": "application/gzip" },
|
|
950
|
+
body,
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
if (!res.ok) {
|
|
954
|
+
let message = `Artifact upload failed with HTTP ${res.status}`;
|
|
955
|
+
try {
|
|
956
|
+
const json = (await res.json()) as { error?: unknown };
|
|
957
|
+
if (typeof json.error === "string" && json.error.length > 0) {
|
|
958
|
+
message = `Artifact upload failed: ${json.error}`;
|
|
959
|
+
}
|
|
960
|
+
} catch {
|
|
961
|
+
// ignore body parse failures
|
|
962
|
+
}
|
|
963
|
+
throw new Error(message);
|
|
964
|
+
}
|
|
965
|
+
},
|
|
966
|
+
catch: (e) =>
|
|
967
|
+
new ArtifactError({
|
|
968
|
+
message: e instanceof Error ? e.message : String(e),
|
|
969
|
+
cause: e,
|
|
970
|
+
}),
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Sort versions: running/provisioning first, then by createdAt descending.
|
|
977
|
+
*/
|
|
978
|
+
function versionSortComparator(a: VersionInfo, b: VersionInfo): number {
|
|
979
|
+
const statusPriority = (s: string) =>
|
|
980
|
+
s === "running" ? 0 : s === "provisioning" ? 1 : 2;
|
|
981
|
+
const aPriority = statusPriority(a.status);
|
|
982
|
+
const bPriority = statusPriority(b.status);
|
|
983
|
+
if (aPriority !== bPriority) return aPriority - bPriority;
|
|
984
|
+
return b.createdAt.localeCompare(a.createdAt);
|
|
985
|
+
}
|