unfazed 0.8.0 → 0.8.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.
package/dist/unfazed.js CHANGED
@@ -464,9 +464,24 @@ export class Unfazed {
464
464
  * the account already holds its cap of waits in progress (16), it polls
465
465
  * instead (`waitOptions.mode: "poll"` forces that), every 0.5 s backing
466
466
  * off to 3 s by default.
467
+ *
468
+ * Pass a {@link CommitDeploymentTarget} instead of an id to wait for the
469
+ * deploy a git import makes of a commit — a restore, a push or a sync
470
+ * deploys before any id exists. The wait covers the import too, and
471
+ * {@link UnfazedDeployError} carries the import's error when it failed on
472
+ * the commit.
473
+ *
474
+ * ```ts
475
+ * const back = await unfazed.restoreVersion({ workspaceId: ws.id, sha });
476
+ * await unfazed.waitForDeployment({
477
+ * workspaceId: back.workspaceID, gitCommit: back.sha, alias: back.deployAlias, after: back.waitAfter,
478
+ * });
479
+ * ```
467
480
  */
468
- async waitForDeployment(deploymentId, waitOptions = {}) {
469
- return (await this.#waitTerminal(deploymentId, waitOptions)).deployment;
481
+ async waitForDeployment(target, waitOptions = {}) {
482
+ if (typeof target !== "string")
483
+ return (await this.#commitWait(target, waitOptions)).deployment;
484
+ return (await this.#waitTerminal(target, waitOptions)).deployment;
470
485
  }
471
486
  async #waitTerminal(deploymentId, waitOptions) {
472
487
  const now = waitOptions.now ?? (() => Date.now());
@@ -518,7 +533,11 @@ export class Unfazed {
518
533
  }
519
534
  throw e;
520
535
  }
536
+ // By id the platform always answers the deployment; null is only for a
537
+ // wait by commit before the import made it.
521
538
  const d = answer.deployment;
539
+ if (!d)
540
+ throw new UnfazedNotFoundError(`unfazed: deployment ${deploymentId} not found`);
522
541
  last = d;
523
542
  if (answer.timedOut) {
524
543
  landing = d.status === "READY";
@@ -529,6 +548,61 @@ export class Unfazed {
529
548
  throw new UnfazedDeployError("FAILED", deploymentId, d, `unfazed: deployment ${deploymentId} ${d.status}${d.error ? `: ${d.error}` : ""}`);
530
549
  }
531
550
  }
551
+ /**
552
+ * The server-side wait by commit: for the import to make the commit's
553
+ * deployment, then for it, one budget for both. There is nothing cheaper to
554
+ * poll, so a refusal past the platform's cap on waits in progress backs off
555
+ * (0.5 s up to 3 s) and asks again.
556
+ */
557
+ async #commitWait(target, waitOptions) {
558
+ const resolved = resolveWaitOptions(waitOptions);
559
+ const deadline = resolved.now() + resolved.timeoutMs;
560
+ const commit = target.gitCommit.slice(0, 7);
561
+ const what = `the deploy of ${commit}${target.alias ? ` to ${target.alias}` : ""}`;
562
+ let last = null;
563
+ let status = "IMPORTING";
564
+ let backoff = 500;
565
+ for (let requests = 1;; requests++) {
566
+ if (resolved.signal?.aborted)
567
+ throw new UnfazedTimeoutError("unfazed: wait aborted");
568
+ const remaining = deadline - resolved.now();
569
+ if (requests > 1 && remaining <= 0) {
570
+ throw new UnfazedDeployError("TIMEOUT", last?.id ?? "", last, `unfazed: ${what} still ${status} after ${resolved.timeoutMs}ms`);
571
+ }
572
+ const seconds = Math.max(1, Math.min(SERVER_WAIT_SECONDS, Math.ceil(remaining / 1000)));
573
+ let answer;
574
+ try {
575
+ answer = (await this.client.query(ops.WaitForCommitDeploymentDocument, {
576
+ workspaceID: target.workspaceId,
577
+ gitCommit: target.gitCommit,
578
+ alias: target.alias ?? null,
579
+ after: target.after ?? null,
580
+ timeoutSeconds: seconds,
581
+ }, { ...(resolved.signal ? { signal: resolved.signal } : {}), timeoutMs: (seconds + 30) * 1000 })).waitForDeployment;
582
+ }
583
+ catch (e) {
584
+ if (!isTooManyWaits(e))
585
+ throw e;
586
+ await resolved.sleep(Math.max(0, Math.min(backoff, deadline - resolved.now())), resolved.signal);
587
+ backoff = Math.min(backoff * 1.5, 3_000);
588
+ continue;
589
+ }
590
+ const d = answer.deployment;
591
+ status = answer.status;
592
+ if (d)
593
+ last = d;
594
+ if (answer.timedOut)
595
+ continue;
596
+ if (!d) {
597
+ // Not timed out and no deployment: the import failed on the commit
598
+ // and nothing imports any more, so no deployment of it is coming.
599
+ throw new UnfazedDeployError("FAILED", "", null, `unfazed: the import of ${commit} failed${answer.importError ? `: ${answer.importError}` : ""}`);
600
+ }
601
+ if (d.status === "READY")
602
+ return { deployment: d, requests };
603
+ throw new UnfazedDeployError("FAILED", d.id, d, `unfazed: deployment ${d.id} (${what}) ${d.status}${d.error ? `: ${d.error}` : ""}`);
604
+ }
605
+ }
532
606
  async #pollWait(deploymentId, waitOptions) {
533
607
  const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
534
608
  const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
@@ -557,12 +631,16 @@ export class Unfazed {
557
631
  return { deployment: full, requests: requests + 1, mode: "poll" };
558
632
  }
559
633
  /**
560
- * The platform's measured phases of a deploy ({@link PlatformTimings}),
561
- * from its timing events. Null when it recorded none (a deploy that is
562
- * still building, or one from before timings existed).
634
+ * The platform's measured phases of a deploy ({@link PlatformTimings}), as
635
+ * it parses them from all of the deployment's timing events
636
+ * (`Deployment.timings`). Null when it recorded none (a deploy that is
637
+ * still building, or one from before timings existed), or for a deployment
638
+ * you cannot see.
563
639
  */
564
640
  async getDeploymentTimings(deploymentId, options) {
565
- return parseDeploymentTimings(await this.getEvents(deploymentId, 100, options));
641
+ const data = await this.client.query(ops.DeploymentTimingsDocument, { id: deploymentId }, options);
642
+ const timings = data.deployment?.timings;
643
+ return timings ? toPlatformTimings(timings) : null;
566
644
  }
567
645
  /**
568
646
  * The timings of a deploy that just went READY. `deployment.phases` is
@@ -964,9 +1042,12 @@ export class Unfazed {
964
1042
  * When the app deploys every push (`deploys: true`), the new commit is
965
1043
  * imported and deployed like a push. That deploy is queued behind the
966
1044
  * restore, so the platform answers before it exists; with `wait: true` the
967
- * SDK waits for the import of the new commit and resolves with the
968
- * deployments it made (READY), or throws {@link UnfazedDeployError} with
969
- * the import's error.
1045
+ * SDK waits for it by commit on the platform ({@link Unfazed.waitForDeployment}
1046
+ * with `gitCommit: sha`, `alias: deployAlias`, `after: waitAfter`) and
1047
+ * resolves with it READY, or throws {@link UnfazedDeployError} with its
1048
+ * build error or the import's. A link with unfazed.json `apps[]` names no
1049
+ * `deployAlias` and deploys once per app: then it follows the import until
1050
+ * every deployment it made is done.
970
1051
  *
971
1052
  * ```ts
972
1053
  * const page = await unfazed.listCommits(ws.id);
@@ -985,18 +1066,19 @@ export class Unfazed {
985
1066
  const restored = data.restoreAppVersion;
986
1067
  if (!options.wait || !restored.deploys)
987
1068
  return restored;
988
- return {
989
- ...restored,
990
- deployments: await this.#waitForImport(options.workspaceId, restored.sha, {
991
- ...options.waitOptions,
992
- ...(options.signal ? { signal: options.signal } : {}),
993
- }),
994
- };
1069
+ const waitOptions = { ...options.waitOptions, ...(options.signal ? { signal: options.signal } : {}) };
1070
+ if (restored.deployAlias) {
1071
+ const { deployment } = await this.#commitWait({ workspaceId: restored.workspaceID, gitCommit: restored.sha, alias: restored.deployAlias, after: restored.waitAfter }, waitOptions);
1072
+ return { ...restored, deployments: [deployment] };
1073
+ }
1074
+ return { ...restored, deployments: await this.#waitForImport(restored.workspaceID, restored.sha, waitOptions) };
995
1075
  }
996
1076
  /**
997
1077
  * Waits until the app's git link has imported `sha` (READY: its deploys
998
1078
  * are done) and returns the deployments of that commit; throws on a FAILED
999
- * import of it.
1079
+ * import of it. Only for a restore whose link names no alias (unfazed.json
1080
+ * `apps[]`): the wait by commit waits for one app's deploy, and which apps
1081
+ * a commit deploys is known only once its import is done.
1000
1082
  */
1001
1083
  async #waitForImport(workspaceId, sha, waitOptions) {
1002
1084
  const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
@@ -1443,51 +1525,123 @@ function toDeploymentEvent(event) {
1443
1525
  message: event.message,
1444
1526
  vmID: event.vmID ?? null,
1445
1527
  metadata: event.metadata ?? null,
1528
+ timings: event.timings ? toPlatformTimings(event.timings) : null,
1446
1529
  createdAt: event.createdAt,
1447
1530
  };
1448
1531
  }
1532
+ const BUILD_FINISHED_EVENT = "deployment.build_finished";
1533
+ const DEPLOY_PHASES_EVENT = "deployment.phases";
1449
1534
  /**
1450
- * A deploy's phases from its events: `deployment.phases` (written when it went
1451
- * READY: build, promotion and its split, total) over `deployment.build_finished`
1452
- * (the build VM's own phases). Every `*_ms` field becomes a number. Null when
1453
- * neither event is there.
1535
+ * A duration as the platform accepts one: a whole, non-negative number of
1536
+ * milliseconds (a number, or a string of digits as metadata carries it). A
1537
+ * fraction or anything else is not a duration; a negative one is a clock gone
1538
+ * backwards. Both are dropped, as the platform drops them.
1454
1539
  */
1455
- export function parseDeploymentTimings(events) {
1456
- const latest = (type) => [...events].reverse().find((e) => e.type === type)?.metadata ?? null;
1457
- const build = latest("deployment.build_finished");
1458
- const ready = latest("deployment.phases");
1459
- if (!build && !ready)
1540
+ function durationMs(value) {
1541
+ let n;
1542
+ if (typeof value === "number")
1543
+ n = value;
1544
+ else if (typeof value === "string" && /^[+-]?\d+$/.test(value.trim()))
1545
+ n = Number(value.trim());
1546
+ else
1460
1547
  return null;
1461
- const merged = { ...build, ...ready };
1462
- const ms = (name) => {
1463
- const value = merged[`${name}_ms`];
1464
- if (value === undefined)
1465
- return null;
1466
- const n = Number(value);
1467
- return Number.isFinite(n) ? n : null;
1468
- };
1548
+ return Number.isSafeInteger(n) && n >= 0 ? n + 0 : null; // + 0: -0 is 0
1549
+ }
1550
+ /** The platform's structured timings, with every duration checked like the metadata's. */
1551
+ function toPlatformTimings(t) {
1469
1552
  const phases = {};
1470
- for (const [key, value] of Object.entries(merged)) {
1471
- if (!key.endsWith("_ms"))
1472
- continue;
1473
- const name = key.slice(0, -3);
1474
- if (name === "total" || name === "build" || name === "promote")
1475
- continue;
1476
- const n = Number(value);
1477
- if (Number.isFinite(n))
1478
- phases[name] = n;
1553
+ if (t.phases && typeof t.phases === "object") {
1554
+ for (const [name, value] of Object.entries(t.phases)) {
1555
+ const ms = durationMs(value);
1556
+ if (ms !== null)
1557
+ phases[name] = ms;
1558
+ }
1479
1559
  }
1480
- const memory = Number(merged["build_memory_mib"]);
1560
+ const memory = durationMs(t.buildMemoryMiB);
1481
1561
  return {
1482
- totalMs: ready ? ms("total") : null,
1483
- buildMs: ms("build"),
1484
- promoteMs: ms("promote"),
1562
+ totalMs: durationMs(t.totalMs),
1563
+ buildMs: durationMs(t.buildMs),
1564
+ promoteMs: durationMs(t.promoteMs),
1485
1565
  phases,
1486
- buildMode: merged["build_mode"] ?? null,
1487
- artifactTransport: merged["artifact_transport"] ?? null,
1488
- buildMemoryMiB: Number.isFinite(memory) && merged["build_memory_mib"] !== undefined ? memory : null,
1566
+ buildMode: t.buildMode || null,
1567
+ artifactTransport: t.artifactTransport || null,
1568
+ buildMemoryMiB: memory ? memory : null,
1489
1569
  };
1490
1570
  }
1571
+ /**
1572
+ * Timing-event metadata read exactly as the platform reads it
1573
+ * (internal/usecases/deploy_timings.go): every `<name>_ms` that is a whole,
1574
+ * non-negative number; `total` only from the READY summary.
1575
+ */
1576
+ function timingsFromMetadata(metadata, ready) {
1577
+ const out = {
1578
+ totalMs: null,
1579
+ buildMs: null,
1580
+ promoteMs: null,
1581
+ phases: {},
1582
+ buildMode: metadata["build_mode"] || null,
1583
+ artifactTransport: metadata["artifact_transport"] || null,
1584
+ buildMemoryMiB: null,
1585
+ };
1586
+ for (const [key, raw] of Object.entries(metadata)) {
1587
+ if (!key.endsWith("_ms") || key === "_ms")
1588
+ continue;
1589
+ const ms = durationMs(raw);
1590
+ if (ms === null)
1591
+ continue;
1592
+ const name = key.slice(0, -3);
1593
+ if (name === "total") {
1594
+ if (ready)
1595
+ out.totalMs = ms;
1596
+ }
1597
+ else if (name === "build")
1598
+ out.buildMs = ms;
1599
+ else if (name === "promote")
1600
+ out.promoteMs = ms;
1601
+ else
1602
+ out.phases[name] = ms;
1603
+ }
1604
+ const memory = durationMs(metadata["build_memory_mib"]);
1605
+ if (memory)
1606
+ out.buildMemoryMiB = memory;
1607
+ return out;
1608
+ }
1609
+ /**
1610
+ * A deploy's phases from its events, as the platform reads them: the latest
1611
+ * `deployment.phases` (written when it went READY: build, promotion and its
1612
+ * split, total) over the latest `deployment.build_finished` (the build VM's
1613
+ * own phases). It prefers the `timings` the platform parsed into each event
1614
+ * ({@link Unfazed.getEvents} reads them); events without them (an older
1615
+ * platform, events built by hand) are parsed from their `*_ms` metadata the
1616
+ * same way. Durations are whole, non-negative milliseconds: anything else is
1617
+ * dropped, as the platform drops it. Null when neither event is there.
1618
+ */
1619
+ export function parseDeploymentTimings(events) {
1620
+ const latest = (type) => {
1621
+ const event = [...events].reverse().find((e) => e.type === type);
1622
+ return event && (event.timings || event.metadata) ? event : null;
1623
+ };
1624
+ const build = latest(BUILD_FINISHED_EVENT);
1625
+ const ready = latest(DEPLOY_PHASES_EVENT);
1626
+ if (!build && !ready)
1627
+ return null;
1628
+ if ((!build || build.timings) && (!ready || ready.timings)) {
1629
+ const b = build?.timings ? toPlatformTimings(build.timings) : null;
1630
+ const r = ready?.timings ? toPlatformTimings(ready.timings) : null;
1631
+ return {
1632
+ totalMs: r?.totalMs ?? null,
1633
+ buildMs: r?.buildMs ?? b?.buildMs ?? null,
1634
+ promoteMs: r?.promoteMs ?? b?.promoteMs ?? null,
1635
+ phases: { ...b?.phases, ...r?.phases },
1636
+ buildMode: r?.buildMode ?? b?.buildMode ?? null,
1637
+ artifactTransport: r?.artifactTransport ?? b?.artifactTransport ?? null,
1638
+ buildMemoryMiB: r?.buildMemoryMiB ?? b?.buildMemoryMiB ?? null,
1639
+ };
1640
+ }
1641
+ // The platform merges the two events' metadata, the READY summary's values
1642
+ // over the build's, and parses the result.
1643
+ return timingsFromMetadata({ ...build?.metadata, ...ready?.metadata }, ready !== null);
1644
+ }
1491
1645
  function errorGroupInput(ref) {
1492
1646
  return { workspaceID: ref.workspaceId, alias: ref.alias, groupID: ref.groupId };
1493
1647
  }