unfazed 0.8.0 → 0.9.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/CHANGELOG.md +80 -0
- package/README.md +93 -5
- package/REFERENCE.md +72 -13
- package/dist/cli/login.d.ts +1 -1
- package/dist/client.d.ts +21 -1
- package/dist/client.js +12 -4
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +49 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -1
- package/dist/generated/gql.d.ts +14 -2
- package/dist/generated/gql.js +5 -2
- package/dist/generated/gql.js.map +1 -1
- package/dist/generated/graphql.d.ts +82 -1
- package/dist/generated/graphql.js +87 -1
- package/dist/generated/graphql.js.map +1 -1
- package/dist/index.d.ts +17 -3
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/oidc.d.ts +161 -0
- package/dist/oidc.js +391 -0
- package/dist/oidc.js.map +1 -0
- package/dist/operations.d.ts +29 -1
- package/dist/operations.js +70 -1
- package/dist/operations.js.map +1 -1
- package/dist/reference.generated.js +1 -1
- package/dist/reference.generated.js.map +1 -1
- package/dist/reference.js +3 -3
- package/dist/reference.js.map +1 -1
- package/dist/unfazed.d.ts +84 -19
- package/dist/unfazed.js +249 -54
- package/dist/unfazed.js.map +1 -1
- package/package.json +1 -1
- package/schema/SOURCE.json +3 -3
- package/schema/schema.graphqls +81 -5
package/dist/unfazed.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { UnfazedClient, type ClientOptions, type RequestOptions } from "./client.js";
|
|
2
|
+
import { UnfazedOIDC } from "./oidc.js";
|
|
2
3
|
import type { AppErrorGroupFieldsFragment, CreateDeploymentInput, DeploymentEventType, DeploymentFieldsFragment, FileCommitFieldsFragment, ListDeploymentsInput, RunFieldsFragment, RunStatus, ServiceMode, ServiceRuntime, SdkAppCommitQuery, SdkAppCommitsQuery, SdkAppErrorGroupQuery, SdkAppErrorsQuery, SdkCreateWorkspaceMutation, SdkDeleteAppMutation, SdkDeploymentEventsQuery, SdkEventTypesQuery, SdkPollEventsQuery, SdkRestoreVersionMutation, SdkRunHarnessesQuery, SdkWorkspaceLiveAppsQuery } from "./generated/graphql.js";
|
|
3
4
|
import { type WaitOptions } from "./wait.js";
|
|
4
5
|
/** A deployment as the workflow layer returns it. */
|
|
@@ -171,9 +172,12 @@ export interface RestoreVersionOptions {
|
|
|
171
172
|
commitMessage?: string;
|
|
172
173
|
/**
|
|
173
174
|
* When the restore deploys (`deploys: true`, an app that deploys every
|
|
174
|
-
* push), wait for that deploy:
|
|
175
|
-
*
|
|
176
|
-
*
|
|
175
|
+
* push), wait for that deploy: the platform's wait by commit
|
|
176
|
+
* ({@link Unfazed.waitForDeployment} with `gitCommit`, `alias` and `after`
|
|
177
|
+
* from the answer) resolves once it is READY; {@link UnfazedDeployError}
|
|
178
|
+
* when it or the import of the new commit failed. A link with unfazed.json
|
|
179
|
+
* `apps[]` (no `deployAlias`) deploys once per app: then the wait follows
|
|
180
|
+
* the import until every deployment it made is done.
|
|
177
181
|
*/
|
|
178
182
|
wait?: boolean;
|
|
179
183
|
/** Timeout/backoff for `wait`; default 10 minutes. */
|
|
@@ -185,10 +189,29 @@ export type RestoreVersionResult = SdkRestoreVersionMutation["restoreAppVersion"
|
|
|
185
189
|
/**
|
|
186
190
|
* With `wait` and `deploys`: the deployments the restore's import made (one
|
|
187
191
|
* per alias it deploys), READY. Absent without `wait` or when nothing
|
|
188
|
-
* deploys.
|
|
192
|
+
* deploys. Without `wait`, wait later with `waitForDeployment({ workspaceId:
|
|
193
|
+
* workspaceID, gitCommit: sha, alias: deployAlias, after: waitAfter })`.
|
|
189
194
|
*/
|
|
190
195
|
deployments?: Deployment[];
|
|
191
196
|
};
|
|
197
|
+
/**
|
|
198
|
+
* The deploy a git import makes of a commit, for
|
|
199
|
+
* {@link Unfazed.waitForDeployment}: a restore, a push or a sync deploys
|
|
200
|
+
* before any deployment id exists, so the commit names it.
|
|
201
|
+
*/
|
|
202
|
+
export interface CommitDeploymentTarget {
|
|
203
|
+
workspaceId: string;
|
|
204
|
+
/** Full 40-character sha. */
|
|
205
|
+
gitCommit: string;
|
|
206
|
+
/** The app that deploys it; required when several apps (unfazed.json `apps[]`) deployed the commit. */
|
|
207
|
+
alias?: string | null;
|
|
208
|
+
/**
|
|
209
|
+
* Count only deployments begun after this point of the platform's
|
|
210
|
+
* deployment order (a restore's `waitAfter`), so an earlier deploy of the
|
|
211
|
+
* same commit never answers.
|
|
212
|
+
*/
|
|
213
|
+
after?: number | null;
|
|
214
|
+
}
|
|
192
215
|
/** A service app's error group (MCP `list_errors`). Title, type, culprit and level are untrusted app text. */
|
|
193
216
|
export type ErrorGroup = AppErrorGroupFieldsFragment;
|
|
194
217
|
/** One page of error groups ({@link Unfazed.listErrors}). */
|
|
@@ -246,8 +269,14 @@ export interface DeploymentEvent {
|
|
|
246
269
|
severity: SdkDeploymentEventsQuery["deploymentEvents"][number]["severity"];
|
|
247
270
|
message: string;
|
|
248
271
|
vmID: string | null;
|
|
249
|
-
/** The event's fields; timing events carry their phases as `*_ms` strings
|
|
272
|
+
/** The event's fields; timing events carry their phases as `*_ms` strings. */
|
|
250
273
|
metadata: Record<string, string> | null;
|
|
274
|
+
/**
|
|
275
|
+
* What a timing event (`deployment.build_finished`, `deployment.phases`)
|
|
276
|
+
* measured, as the platform parsed it; null for any other event.
|
|
277
|
+
* {@link parseDeploymentTimings} prefers it to `metadata`.
|
|
278
|
+
*/
|
|
279
|
+
timings: PlatformTimings | null;
|
|
251
280
|
createdAt: string;
|
|
252
281
|
}
|
|
253
282
|
/**
|
|
@@ -549,9 +578,19 @@ export interface UnfazedOptions extends ClientOptions {
|
|
|
549
578
|
*/
|
|
550
579
|
export declare class Unfazed {
|
|
551
580
|
#private;
|
|
552
|
-
/**
|
|
553
|
-
|
|
581
|
+
/**
|
|
582
|
+
* Workload identity tokens for the app this code runs in
|
|
583
|
+
* (`oidc.token(audience)`, `oidc.fetchWithToken(audience)`). They need only
|
|
584
|
+
* the app's `UNFZ_STATE_TOKEN`, so they work in an `Unfazed` made without
|
|
585
|
+
* an API token or a login.
|
|
586
|
+
*/
|
|
587
|
+
readonly oidc: UnfazedOIDC;
|
|
554
588
|
constructor(options?: UnfazedOptions);
|
|
589
|
+
/**
|
|
590
|
+
* The underlying typed GraphQL client. Use it for un-wrapped operations.
|
|
591
|
+
* Throws {@link UnfazedAuthError} when there is no token and no login.
|
|
592
|
+
*/
|
|
593
|
+
get client(): UnfazedClient;
|
|
555
594
|
/** Who this token authenticates as, plus usage and resource limits. */
|
|
556
595
|
whoami(options?: RequestOptions): Promise<{
|
|
557
596
|
kind: import("./index.js").CurrentActorKind;
|
|
@@ -862,12 +901,27 @@ export declare class Unfazed {
|
|
|
862
901
|
* the account already holds its cap of waits in progress (16), it polls
|
|
863
902
|
* instead (`waitOptions.mode: "poll"` forces that), every 0.5 s backing
|
|
864
903
|
* off to 3 s by default.
|
|
904
|
+
*
|
|
905
|
+
* Pass a {@link CommitDeploymentTarget} instead of an id to wait for the
|
|
906
|
+
* deploy a git import makes of a commit — a restore, a push or a sync
|
|
907
|
+
* deploys before any id exists. The wait covers the import too, and
|
|
908
|
+
* {@link UnfazedDeployError} carries the import's error when it failed on
|
|
909
|
+
* the commit.
|
|
910
|
+
*
|
|
911
|
+
* ```ts
|
|
912
|
+
* const back = await unfazed.restoreVersion({ workspaceId: ws.id, sha });
|
|
913
|
+
* await unfazed.waitForDeployment({
|
|
914
|
+
* workspaceId: back.workspaceID, gitCommit: back.sha, alias: back.deployAlias, after: back.waitAfter,
|
|
915
|
+
* });
|
|
916
|
+
* ```
|
|
865
917
|
*/
|
|
866
|
-
waitForDeployment(
|
|
918
|
+
waitForDeployment(target: string | CommitDeploymentTarget, waitOptions?: WaitOptions): Promise<Deployment>;
|
|
867
919
|
/**
|
|
868
|
-
* The platform's measured phases of a deploy ({@link PlatformTimings}),
|
|
869
|
-
*
|
|
870
|
-
*
|
|
920
|
+
* The platform's measured phases of a deploy ({@link PlatformTimings}), as
|
|
921
|
+
* it parses them from all of the deployment's timing events
|
|
922
|
+
* (`Deployment.timings`). Null when it recorded none (a deploy that is
|
|
923
|
+
* still building, or one from before timings existed), or for a deployment
|
|
924
|
+
* you cannot see.
|
|
871
925
|
*/
|
|
872
926
|
getDeploymentTimings(deploymentId: string, options?: RequestOptions): Promise<PlatformTimings | null>;
|
|
873
927
|
/** Reads one deployment by id. Null when absent. */
|
|
@@ -1162,9 +1216,12 @@ export declare class Unfazed {
|
|
|
1162
1216
|
* When the app deploys every push (`deploys: true`), the new commit is
|
|
1163
1217
|
* imported and deployed like a push. That deploy is queued behind the
|
|
1164
1218
|
* restore, so the platform answers before it exists; with `wait: true` the
|
|
1165
|
-
* SDK waits for
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1219
|
+
* SDK waits for it by commit on the platform ({@link Unfazed.waitForDeployment}
|
|
1220
|
+
* with `gitCommit: sha`, `alias: deployAlias`, `after: waitAfter`) and
|
|
1221
|
+
* resolves with it READY, or throws {@link UnfazedDeployError} with its
|
|
1222
|
+
* build error or the import's. A link with unfazed.json `apps[]` names no
|
|
1223
|
+
* `deployAlias` and deploys once per app: then it follows the import until
|
|
1224
|
+
* every deployment it made is done.
|
|
1168
1225
|
*
|
|
1169
1226
|
* ```ts
|
|
1170
1227
|
* const page = await unfazed.listCommits(ws.id);
|
|
@@ -1406,10 +1463,18 @@ export declare class Unfazed {
|
|
|
1406
1463
|
truncated: boolean;
|
|
1407
1464
|
}>;
|
|
1408
1465
|
}
|
|
1466
|
+
/** An event {@link parseDeploymentTimings} reads: `timings` when the platform parsed it, else `metadata`. */
|
|
1467
|
+
export type TimingEvent = Pick<DeploymentEvent, "type" | "metadata"> & {
|
|
1468
|
+
timings?: PlatformTimings | null;
|
|
1469
|
+
};
|
|
1409
1470
|
/**
|
|
1410
|
-
* A deploy's phases from its events
|
|
1411
|
-
* READY: build, promotion and its
|
|
1412
|
-
*
|
|
1413
|
-
*
|
|
1471
|
+
* A deploy's phases from its events, as the platform reads them: the latest
|
|
1472
|
+
* `deployment.phases` (written when it went READY: build, promotion and its
|
|
1473
|
+
* split, total) over the latest `deployment.build_finished` (the build VM's
|
|
1474
|
+
* own phases). It prefers the `timings` the platform parsed into each event
|
|
1475
|
+
* ({@link Unfazed.getEvents} reads them); events without them (an older
|
|
1476
|
+
* platform, events built by hand) are parsed from their `*_ms` metadata the
|
|
1477
|
+
* same way. Durations are whole, non-negative milliseconds: anything else is
|
|
1478
|
+
* dropped, as the platform drops it. Null when neither event is there.
|
|
1414
1479
|
*/
|
|
1415
|
-
export declare function parseDeploymentTimings(events: readonly
|
|
1480
|
+
export declare function parseDeploymentTimings(events: readonly TimingEvent[]): PlatformTimings | null;
|
package/dist/unfazed.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { UnfazedClient } from "./client.js";
|
|
2
|
-
import { DEPLOYMENT_NOT_FOUND, UnfazedConflictError, UnfazedDeployError, UnfazedError, UnfazedGraphQLError, UnfazedNotFoundError, UnfazedRunError, UnfazedTimeoutError, UnfazedUnconfirmedAppsError, UnfazedWriteError, WORKSPACE_NOT_FOUND, isAppNotFound, isDuplicateWorkspaceName, isNotFoundSentinel as isNotFound, isUnconfirmedAppsRefusal, isTooManyWaits, isUnsupportedField, looksLikeCASConflict, } from "./errors.js";
|
|
2
|
+
import { DEPLOYMENT_NOT_FOUND, UnfazedAuthError, UnfazedConflictError, UnfazedDeployError, UnfazedError, UnfazedGraphQLError, UnfazedNotFoundError, UnfazedRunError, UnfazedTimeoutError, UnfazedUnconfirmedAppsError, UnfazedWriteError, WORKSPACE_NOT_FOUND, isAppNotFound, isDuplicateWorkspaceName, isNotFoundSentinel as isNotFound, isUnconfirmedAppsRefusal, isTooManyWaits, isUnsupportedField, looksLikeCASConflict, } from "./errors.js";
|
|
3
|
+
import { UnfazedOIDC } from "./oidc.js";
|
|
3
4
|
import * as ops from "./operations.js";
|
|
4
5
|
import { pollWithBackoff, resolveWaitOptions } from "./wait.js";
|
|
5
6
|
/**
|
|
@@ -20,11 +21,51 @@ import { pollWithBackoff, resolveWaitOptions } from "./wait.js";
|
|
|
20
21
|
* ```
|
|
21
22
|
*/
|
|
22
23
|
export class Unfazed {
|
|
23
|
-
|
|
24
|
-
client
|
|
24
|
+
#client;
|
|
25
|
+
/** Why there is no client: no token and no stored login. */
|
|
26
|
+
#noClient;
|
|
27
|
+
/**
|
|
28
|
+
* Workload identity tokens for the app this code runs in
|
|
29
|
+
* (`oidc.token(audience)`, `oidc.fetchWithToken(audience)`). They need only
|
|
30
|
+
* the app's `UNFZ_STATE_TOKEN`, so they work in an `Unfazed` made without
|
|
31
|
+
* an API token or a login.
|
|
32
|
+
*/
|
|
33
|
+
oidc;
|
|
25
34
|
constructor(options = {}) {
|
|
26
35
|
const { client, ...clientOptions } = options;
|
|
27
|
-
|
|
36
|
+
let made = client;
|
|
37
|
+
if (!made) {
|
|
38
|
+
try {
|
|
39
|
+
made = new UnfazedClient(clientOptions);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
// An app has its state token but no API token: `oidc` still works,
|
|
43
|
+
// and the first call that needs the API says how to sign in.
|
|
44
|
+
if (!(error instanceof UnfazedAuthError))
|
|
45
|
+
throw error;
|
|
46
|
+
this.#noClient = error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
this.#client = made;
|
|
50
|
+
// Tokens come from the platform the client talks to: its endpoint already
|
|
51
|
+
// resolved `endpoint`, `baseUrl`, `UNFZ_API_URL` and the stored login.
|
|
52
|
+
const endpoint = made?.endpoint ?? options.endpoint;
|
|
53
|
+
this.oidc = new UnfazedOIDC({
|
|
54
|
+
...(endpoint ? { endpoint } : {}),
|
|
55
|
+
...(!made && options.baseUrl ? { baseUrl: options.baseUrl } : {}),
|
|
56
|
+
...(options.env ? { env: options.env } : {}),
|
|
57
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
58
|
+
...(options.userAgent !== undefined ? { userAgent: options.userAgent } : {}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The underlying typed GraphQL client. Use it for un-wrapped operations.
|
|
63
|
+
* Throws {@link UnfazedAuthError} when there is no token and no login.
|
|
64
|
+
*/
|
|
65
|
+
get client() {
|
|
66
|
+
if (this.#client)
|
|
67
|
+
return this.#client;
|
|
68
|
+
throw new UnfazedAuthError(this.#noClient?.message ?? "unfazed: no API client", { cause: this.#noClient });
|
|
28
69
|
}
|
|
29
70
|
/* ---------------------------------------------------------------- identity */
|
|
30
71
|
/** Who this token authenticates as, plus usage and resource limits. */
|
|
@@ -464,9 +505,24 @@ export class Unfazed {
|
|
|
464
505
|
* the account already holds its cap of waits in progress (16), it polls
|
|
465
506
|
* instead (`waitOptions.mode: "poll"` forces that), every 0.5 s backing
|
|
466
507
|
* off to 3 s by default.
|
|
508
|
+
*
|
|
509
|
+
* Pass a {@link CommitDeploymentTarget} instead of an id to wait for the
|
|
510
|
+
* deploy a git import makes of a commit — a restore, a push or a sync
|
|
511
|
+
* deploys before any id exists. The wait covers the import too, and
|
|
512
|
+
* {@link UnfazedDeployError} carries the import's error when it failed on
|
|
513
|
+
* the commit.
|
|
514
|
+
*
|
|
515
|
+
* ```ts
|
|
516
|
+
* const back = await unfazed.restoreVersion({ workspaceId: ws.id, sha });
|
|
517
|
+
* await unfazed.waitForDeployment({
|
|
518
|
+
* workspaceId: back.workspaceID, gitCommit: back.sha, alias: back.deployAlias, after: back.waitAfter,
|
|
519
|
+
* });
|
|
520
|
+
* ```
|
|
467
521
|
*/
|
|
468
|
-
async waitForDeployment(
|
|
469
|
-
|
|
522
|
+
async waitForDeployment(target, waitOptions = {}) {
|
|
523
|
+
if (typeof target !== "string")
|
|
524
|
+
return (await this.#commitWait(target, waitOptions)).deployment;
|
|
525
|
+
return (await this.#waitTerminal(target, waitOptions)).deployment;
|
|
470
526
|
}
|
|
471
527
|
async #waitTerminal(deploymentId, waitOptions) {
|
|
472
528
|
const now = waitOptions.now ?? (() => Date.now());
|
|
@@ -518,7 +574,11 @@ export class Unfazed {
|
|
|
518
574
|
}
|
|
519
575
|
throw e;
|
|
520
576
|
}
|
|
577
|
+
// By id the platform always answers the deployment; null is only for a
|
|
578
|
+
// wait by commit before the import made it.
|
|
521
579
|
const d = answer.deployment;
|
|
580
|
+
if (!d)
|
|
581
|
+
throw new UnfazedNotFoundError(`unfazed: deployment ${deploymentId} not found`);
|
|
522
582
|
last = d;
|
|
523
583
|
if (answer.timedOut) {
|
|
524
584
|
landing = d.status === "READY";
|
|
@@ -529,6 +589,61 @@ export class Unfazed {
|
|
|
529
589
|
throw new UnfazedDeployError("FAILED", deploymentId, d, `unfazed: deployment ${deploymentId} ${d.status}${d.error ? `: ${d.error}` : ""}`);
|
|
530
590
|
}
|
|
531
591
|
}
|
|
592
|
+
/**
|
|
593
|
+
* The server-side wait by commit: for the import to make the commit's
|
|
594
|
+
* deployment, then for it, one budget for both. There is nothing cheaper to
|
|
595
|
+
* poll, so a refusal past the platform's cap on waits in progress backs off
|
|
596
|
+
* (0.5 s up to 3 s) and asks again.
|
|
597
|
+
*/
|
|
598
|
+
async #commitWait(target, waitOptions) {
|
|
599
|
+
const resolved = resolveWaitOptions(waitOptions);
|
|
600
|
+
const deadline = resolved.now() + resolved.timeoutMs;
|
|
601
|
+
const commit = target.gitCommit.slice(0, 7);
|
|
602
|
+
const what = `the deploy of ${commit}${target.alias ? ` to ${target.alias}` : ""}`;
|
|
603
|
+
let last = null;
|
|
604
|
+
let status = "IMPORTING";
|
|
605
|
+
let backoff = 500;
|
|
606
|
+
for (let requests = 1;; requests++) {
|
|
607
|
+
if (resolved.signal?.aborted)
|
|
608
|
+
throw new UnfazedTimeoutError("unfazed: wait aborted");
|
|
609
|
+
const remaining = deadline - resolved.now();
|
|
610
|
+
if (requests > 1 && remaining <= 0) {
|
|
611
|
+
throw new UnfazedDeployError("TIMEOUT", last?.id ?? "", last, `unfazed: ${what} still ${status} after ${resolved.timeoutMs}ms`);
|
|
612
|
+
}
|
|
613
|
+
const seconds = Math.max(1, Math.min(SERVER_WAIT_SECONDS, Math.ceil(remaining / 1000)));
|
|
614
|
+
let answer;
|
|
615
|
+
try {
|
|
616
|
+
answer = (await this.client.query(ops.WaitForCommitDeploymentDocument, {
|
|
617
|
+
workspaceID: target.workspaceId,
|
|
618
|
+
gitCommit: target.gitCommit,
|
|
619
|
+
alias: target.alias ?? null,
|
|
620
|
+
after: target.after ?? null,
|
|
621
|
+
timeoutSeconds: seconds,
|
|
622
|
+
}, { ...(resolved.signal ? { signal: resolved.signal } : {}), timeoutMs: (seconds + 30) * 1000 })).waitForDeployment;
|
|
623
|
+
}
|
|
624
|
+
catch (e) {
|
|
625
|
+
if (!isTooManyWaits(e))
|
|
626
|
+
throw e;
|
|
627
|
+
await resolved.sleep(Math.max(0, Math.min(backoff, deadline - resolved.now())), resolved.signal);
|
|
628
|
+
backoff = Math.min(backoff * 1.5, 3_000);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const d = answer.deployment;
|
|
632
|
+
status = answer.status;
|
|
633
|
+
if (d)
|
|
634
|
+
last = d;
|
|
635
|
+
if (answer.timedOut)
|
|
636
|
+
continue;
|
|
637
|
+
if (!d) {
|
|
638
|
+
// Not timed out and no deployment: the import failed on the commit
|
|
639
|
+
// and nothing imports any more, so no deployment of it is coming.
|
|
640
|
+
throw new UnfazedDeployError("FAILED", "", null, `unfazed: the import of ${commit} failed${answer.importError ? `: ${answer.importError}` : ""}`);
|
|
641
|
+
}
|
|
642
|
+
if (d.status === "READY")
|
|
643
|
+
return { deployment: d, requests };
|
|
644
|
+
throw new UnfazedDeployError("FAILED", d.id, d, `unfazed: deployment ${d.id} (${what}) ${d.status}${d.error ? `: ${d.error}` : ""}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
532
647
|
async #pollWait(deploymentId, waitOptions) {
|
|
533
648
|
const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
|
|
534
649
|
const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
|
|
@@ -557,12 +672,16 @@ export class Unfazed {
|
|
|
557
672
|
return { deployment: full, requests: requests + 1, mode: "poll" };
|
|
558
673
|
}
|
|
559
674
|
/**
|
|
560
|
-
* The platform's measured phases of a deploy ({@link PlatformTimings}),
|
|
561
|
-
*
|
|
562
|
-
*
|
|
675
|
+
* The platform's measured phases of a deploy ({@link PlatformTimings}), as
|
|
676
|
+
* it parses them from all of the deployment's timing events
|
|
677
|
+
* (`Deployment.timings`). Null when it recorded none (a deploy that is
|
|
678
|
+
* still building, or one from before timings existed), or for a deployment
|
|
679
|
+
* you cannot see.
|
|
563
680
|
*/
|
|
564
681
|
async getDeploymentTimings(deploymentId, options) {
|
|
565
|
-
|
|
682
|
+
const data = await this.client.query(ops.DeploymentTimingsDocument, { id: deploymentId }, options);
|
|
683
|
+
const timings = data.deployment?.timings;
|
|
684
|
+
return timings ? toPlatformTimings(timings) : null;
|
|
566
685
|
}
|
|
567
686
|
/**
|
|
568
687
|
* The timings of a deploy that just went READY. `deployment.phases` is
|
|
@@ -964,9 +1083,12 @@ export class Unfazed {
|
|
|
964
1083
|
* When the app deploys every push (`deploys: true`), the new commit is
|
|
965
1084
|
* imported and deployed like a push. That deploy is queued behind the
|
|
966
1085
|
* restore, so the platform answers before it exists; with `wait: true` the
|
|
967
|
-
* SDK waits for
|
|
968
|
-
*
|
|
969
|
-
*
|
|
1086
|
+
* SDK waits for it by commit on the platform ({@link Unfazed.waitForDeployment}
|
|
1087
|
+
* with `gitCommit: sha`, `alias: deployAlias`, `after: waitAfter`) and
|
|
1088
|
+
* resolves with it READY, or throws {@link UnfazedDeployError} with its
|
|
1089
|
+
* build error or the import's. A link with unfazed.json `apps[]` names no
|
|
1090
|
+
* `deployAlias` and deploys once per app: then it follows the import until
|
|
1091
|
+
* every deployment it made is done.
|
|
970
1092
|
*
|
|
971
1093
|
* ```ts
|
|
972
1094
|
* const page = await unfazed.listCommits(ws.id);
|
|
@@ -985,18 +1107,19 @@ export class Unfazed {
|
|
|
985
1107
|
const restored = data.restoreAppVersion;
|
|
986
1108
|
if (!options.wait || !restored.deploys)
|
|
987
1109
|
return restored;
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
};
|
|
1110
|
+
const waitOptions = { ...options.waitOptions, ...(options.signal ? { signal: options.signal } : {}) };
|
|
1111
|
+
if (restored.deployAlias) {
|
|
1112
|
+
const { deployment } = await this.#commitWait({ workspaceId: restored.workspaceID, gitCommit: restored.sha, alias: restored.deployAlias, after: restored.waitAfter }, waitOptions);
|
|
1113
|
+
return { ...restored, deployments: [deployment] };
|
|
1114
|
+
}
|
|
1115
|
+
return { ...restored, deployments: await this.#waitForImport(restored.workspaceID, restored.sha, waitOptions) };
|
|
995
1116
|
}
|
|
996
1117
|
/**
|
|
997
1118
|
* Waits until the app's git link has imported `sha` (READY: its deploys
|
|
998
1119
|
* are done) and returns the deployments of that commit; throws on a FAILED
|
|
999
|
-
* import of it.
|
|
1120
|
+
* import of it. Only for a restore whose link names no alias (unfazed.json
|
|
1121
|
+
* `apps[]`): the wait by commit waits for one app's deploy, and which apps
|
|
1122
|
+
* a commit deploys is known only once its import is done.
|
|
1000
1123
|
*/
|
|
1001
1124
|
async #waitForImport(workspaceId, sha, waitOptions) {
|
|
1002
1125
|
const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
|
|
@@ -1443,51 +1566,123 @@ function toDeploymentEvent(event) {
|
|
|
1443
1566
|
message: event.message,
|
|
1444
1567
|
vmID: event.vmID ?? null,
|
|
1445
1568
|
metadata: event.metadata ?? null,
|
|
1569
|
+
timings: event.timings ? toPlatformTimings(event.timings) : null,
|
|
1446
1570
|
createdAt: event.createdAt,
|
|
1447
1571
|
};
|
|
1448
1572
|
}
|
|
1573
|
+
const BUILD_FINISHED_EVENT = "deployment.build_finished";
|
|
1574
|
+
const DEPLOY_PHASES_EVENT = "deployment.phases";
|
|
1449
1575
|
/**
|
|
1450
|
-
* A
|
|
1451
|
-
*
|
|
1452
|
-
*
|
|
1453
|
-
*
|
|
1576
|
+
* A duration as the platform accepts one: a whole, non-negative number of
|
|
1577
|
+
* milliseconds (a number, or a string of digits as metadata carries it). A
|
|
1578
|
+
* fraction or anything else is not a duration; a negative one is a clock gone
|
|
1579
|
+
* backwards. Both are dropped, as the platform drops them.
|
|
1454
1580
|
*/
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
if (
|
|
1581
|
+
function durationMs(value) {
|
|
1582
|
+
let n;
|
|
1583
|
+
if (typeof value === "number")
|
|
1584
|
+
n = value;
|
|
1585
|
+
else if (typeof value === "string" && /^[+-]?\d+$/.test(value.trim()))
|
|
1586
|
+
n = Number(value.trim());
|
|
1587
|
+
else
|
|
1460
1588
|
return null;
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
return null;
|
|
1466
|
-
const n = Number(value);
|
|
1467
|
-
return Number.isFinite(n) ? n : null;
|
|
1468
|
-
};
|
|
1589
|
+
return Number.isSafeInteger(n) && n >= 0 ? n + 0 : null; // + 0: -0 is 0
|
|
1590
|
+
}
|
|
1591
|
+
/** The platform's structured timings, with every duration checked like the metadata's. */
|
|
1592
|
+
function toPlatformTimings(t) {
|
|
1469
1593
|
const phases = {};
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
const n = Number(value);
|
|
1477
|
-
if (Number.isFinite(n))
|
|
1478
|
-
phases[name] = n;
|
|
1594
|
+
if (t.phases && typeof t.phases === "object") {
|
|
1595
|
+
for (const [name, value] of Object.entries(t.phases)) {
|
|
1596
|
+
const ms = durationMs(value);
|
|
1597
|
+
if (ms !== null)
|
|
1598
|
+
phases[name] = ms;
|
|
1599
|
+
}
|
|
1479
1600
|
}
|
|
1480
|
-
const memory =
|
|
1601
|
+
const memory = durationMs(t.buildMemoryMiB);
|
|
1481
1602
|
return {
|
|
1482
|
-
totalMs:
|
|
1483
|
-
buildMs:
|
|
1484
|
-
promoteMs:
|
|
1603
|
+
totalMs: durationMs(t.totalMs),
|
|
1604
|
+
buildMs: durationMs(t.buildMs),
|
|
1605
|
+
promoteMs: durationMs(t.promoteMs),
|
|
1485
1606
|
phases,
|
|
1486
|
-
buildMode:
|
|
1487
|
-
artifactTransport:
|
|
1488
|
-
buildMemoryMiB:
|
|
1607
|
+
buildMode: t.buildMode || null,
|
|
1608
|
+
artifactTransport: t.artifactTransport || null,
|
|
1609
|
+
buildMemoryMiB: memory ? memory : null,
|
|
1489
1610
|
};
|
|
1490
1611
|
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Timing-event metadata read exactly as the platform reads it
|
|
1614
|
+
* (internal/usecases/deploy_timings.go): every `<name>_ms` that is a whole,
|
|
1615
|
+
* non-negative number; `total` only from the READY summary.
|
|
1616
|
+
*/
|
|
1617
|
+
function timingsFromMetadata(metadata, ready) {
|
|
1618
|
+
const out = {
|
|
1619
|
+
totalMs: null,
|
|
1620
|
+
buildMs: null,
|
|
1621
|
+
promoteMs: null,
|
|
1622
|
+
phases: {},
|
|
1623
|
+
buildMode: metadata["build_mode"] || null,
|
|
1624
|
+
artifactTransport: metadata["artifact_transport"] || null,
|
|
1625
|
+
buildMemoryMiB: null,
|
|
1626
|
+
};
|
|
1627
|
+
for (const [key, raw] of Object.entries(metadata)) {
|
|
1628
|
+
if (!key.endsWith("_ms") || key === "_ms")
|
|
1629
|
+
continue;
|
|
1630
|
+
const ms = durationMs(raw);
|
|
1631
|
+
if (ms === null)
|
|
1632
|
+
continue;
|
|
1633
|
+
const name = key.slice(0, -3);
|
|
1634
|
+
if (name === "total") {
|
|
1635
|
+
if (ready)
|
|
1636
|
+
out.totalMs = ms;
|
|
1637
|
+
}
|
|
1638
|
+
else if (name === "build")
|
|
1639
|
+
out.buildMs = ms;
|
|
1640
|
+
else if (name === "promote")
|
|
1641
|
+
out.promoteMs = ms;
|
|
1642
|
+
else
|
|
1643
|
+
out.phases[name] = ms;
|
|
1644
|
+
}
|
|
1645
|
+
const memory = durationMs(metadata["build_memory_mib"]);
|
|
1646
|
+
if (memory)
|
|
1647
|
+
out.buildMemoryMiB = memory;
|
|
1648
|
+
return out;
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* A deploy's phases from its events, as the platform reads them: the latest
|
|
1652
|
+
* `deployment.phases` (written when it went READY: build, promotion and its
|
|
1653
|
+
* split, total) over the latest `deployment.build_finished` (the build VM's
|
|
1654
|
+
* own phases). It prefers the `timings` the platform parsed into each event
|
|
1655
|
+
* ({@link Unfazed.getEvents} reads them); events without them (an older
|
|
1656
|
+
* platform, events built by hand) are parsed from their `*_ms` metadata the
|
|
1657
|
+
* same way. Durations are whole, non-negative milliseconds: anything else is
|
|
1658
|
+
* dropped, as the platform drops it. Null when neither event is there.
|
|
1659
|
+
*/
|
|
1660
|
+
export function parseDeploymentTimings(events) {
|
|
1661
|
+
const latest = (type) => {
|
|
1662
|
+
const event = [...events].reverse().find((e) => e.type === type);
|
|
1663
|
+
return event && (event.timings || event.metadata) ? event : null;
|
|
1664
|
+
};
|
|
1665
|
+
const build = latest(BUILD_FINISHED_EVENT);
|
|
1666
|
+
const ready = latest(DEPLOY_PHASES_EVENT);
|
|
1667
|
+
if (!build && !ready)
|
|
1668
|
+
return null;
|
|
1669
|
+
if ((!build || build.timings) && (!ready || ready.timings)) {
|
|
1670
|
+
const b = build?.timings ? toPlatformTimings(build.timings) : null;
|
|
1671
|
+
const r = ready?.timings ? toPlatformTimings(ready.timings) : null;
|
|
1672
|
+
return {
|
|
1673
|
+
totalMs: r?.totalMs ?? null,
|
|
1674
|
+
buildMs: r?.buildMs ?? b?.buildMs ?? null,
|
|
1675
|
+
promoteMs: r?.promoteMs ?? b?.promoteMs ?? null,
|
|
1676
|
+
phases: { ...b?.phases, ...r?.phases },
|
|
1677
|
+
buildMode: r?.buildMode ?? b?.buildMode ?? null,
|
|
1678
|
+
artifactTransport: r?.artifactTransport ?? b?.artifactTransport ?? null,
|
|
1679
|
+
buildMemoryMiB: r?.buildMemoryMiB ?? b?.buildMemoryMiB ?? null,
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
// The platform merges the two events' metadata, the READY summary's values
|
|
1683
|
+
// over the build's, and parses the result.
|
|
1684
|
+
return timingsFromMetadata({ ...build?.metadata, ...ready?.metadata }, ready !== null);
|
|
1685
|
+
}
|
|
1491
1686
|
function errorGroupInput(ref) {
|
|
1492
1687
|
return { workspaceID: ref.workspaceId, alias: ref.alias, groupID: ref.groupId };
|
|
1493
1688
|
}
|