Line data Source code
1 : import * as React from 'react';
2 :
3 : import { useLogger } from '../logger/logger_provider.js';
4 : import { awaitAndSet, Result } from '../../utils/result.js';
5 : import { Logger, stringifyError } from '../logger/logger.js';
6 : import { createTrace } from '../logger/trace_context.js';
7 : import { DASHQL_LATEST_RELEASE_MANIFEST, DASHQL_STABLE_RELEASE_MANIFEST } from '../../globals.js';
8 : import { LATEST_RELEASE_MANIFEST_CTX, LATEST_UPDATE_MANIFEST_CTX, INSTALLATION_STATUS_CTX, STABLE_RELEASE_MANIFEST_CTX, STABLE_UPDATE_MANIFEST_CTX, VERSION_CHECK_CTX, VERSION_CHECK_REFRESH_CTX, VersionCheckStatusCode, useVersionCheckRefreshState } from './version_check.js';
9 :
10 1 : const LOG_CTX = "version_check";
11 :
12 : type Props = {
13 : children: React.ReactElement;
14 : };
15 :
16 : /// A release bundle
17 : export interface ReleaseBundle {
18 : url: URL;
19 : signature: string | null;
20 : name: string;
21 : bundle_type: string;
22 : targets: string[];
23 : }
24 :
25 : /// A release manifest
26 : export interface ReleaseManifest {
27 : release_id: string;
28 : pub_date: Date;
29 : version: string;
30 : git_commit_hash: string;
31 : git_commit_url: string;
32 : update_manifest_url: string;
33 : bundles: ReleaseBundle[];
34 : }
35 :
36 1 : function parseReleaseManifest(raw: any): ReleaseManifest {
37 1 : if (raw.pub_date) {
38 1 : raw.pub_date = new Date(Date.parse(raw.pub_date));
39 : }
40 1 : for (const bundle of raw.bundles) {
41 1 : bundle.url = new URL(bundle.url);
42 : }
43 1 : return raw as ReleaseManifest;
44 1 : }
45 :
46 : /// A release channel
47 : export type ReleaseChannel = "stable" | "latest";
48 :
49 : /// Detect the release channel of an installed version from its version scheme.
50 : ///
51 : /// Releases are versioned as `MAJOR.MINOR.PATCH` for stable builds and
52 : /// `MAJOR.MINOR.PATCH-dev.N` for prereleases on the latest channel (see `bazel/versioning.bzl` and
53 : /// `dashql-pack/src/release_version.rs`, which derive the channel from the git iteration).
54 : /// A prerelease suffix therefore uniquely identifies a build of the latest channel.
55 1 : export function detectReleaseChannel(version: string): ReleaseChannel {
56 1 : return version.includes("-") ? "latest" : "stable";
57 1 : }
58 :
59 : interface ParsedVersion {
60 : major: number;
61 : minor: number;
62 : patch: number;
63 : prerelease: string | null;
64 : }
65 :
66 1 : function parseVersion(version: string): ParsedVersion {
67 1 : const [core, prerelease] = version.split("-", 2);
68 1 : const [major, minor, patch] = core.split(".").map(x => parseInt(x, 10) || 0);
69 1 : return { major, minor, patch, prerelease: prerelease ?? null };
70 1 : }
71 :
72 : /// Compare the dot-separated identifiers of two semver prerelease strings (e.g. "dev.5").
73 1 : function comparePrerelease(a: string, b: string): number {
74 1 : const as = a.split(".");
75 1 : const bs = b.split(".");
76 1 : const n = Math.max(as.length, bs.length);
77 1 : for (let i = 0; i < n; ++i) {
78 1 : const ai = as[i];
79 1 : const bi = bs[i];
80 : // A smaller set of prerelease fields has lower precedence
81 1 : if (ai === undefined) return -1;
82 1 : if (bi === undefined) return 1;
83 1 : const aNum = /^\d+$/.test(ai);
84 1 : const bNum = /^\d+$/.test(bi);
85 1 : if (aNum && bNum) {
86 1 : const x = parseInt(ai, 10);
87 1 : const y = parseInt(bi, 10);
88 1 : if (x !== y) return x < y ? -1 : 1;
89 1 : } else if (aNum !== bNum) {
90 : // Numeric identifiers always have lower precedence than non-numeric ones
91 0 : return aNum ? -1 : 1;
92 1 : } else if (ai !== bi) {
93 0 : return ai < bi ? -1 : 1;
94 : }
95 1 : }
96 0 : return 0;
97 1 : }
98 :
99 : /// Compare two release versions following semver precedence.
100 : /// Returns a negative number if `a < b`, zero if equal, and a positive number if `a > b`.
101 1 : export function compareReleaseVersions(a: string, b: string): number {
102 1 : const pa = parseVersion(a);
103 1 : const pb = parseVersion(b);
104 1 : if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
105 1 : if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
106 1 : if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
107 : // Equal core version: a build without a prerelease suffix outranks a prerelease of the same core
108 1 : if (pa.prerelease == null && pb.prerelease == null) return 0;
109 1 : if (pa.prerelease == null) return 1;
110 1 : if (pb.prerelease == null) return -1;
111 1 : return comparePrerelease(pa.prerelease, pb.prerelease);
112 1 : }
113 :
114 : /// Whether a release should be offered for installation. Installs only move forward, also across
115 : /// channels: a newer latest build may have migrated the local storage to a schema an older stable cannot read.
116 1 : export function canInstallReleaseVersion(targetVersion: string, currentVersion: string): boolean {
117 1 : return compareReleaseVersions(targetVersion, currentVersion) > 0;
118 1 : }
119 :
120 : /// Load the release manifest
121 1 : export async function loadReleaseManifest(channel: ReleaseChannel, url: URL, logger: Logger): Promise<ReleaseManifest> {
122 1 : const traced = logger.withTrace(createTrace());
123 1 : const start = performance.now();
124 1 : traced.info(`Fetching release manifest`, { "channel": channel }, LOG_CTX);
125 1 : try {
126 : // Fetch the release manifest
127 1 : const manifestRequest = await fetch(url, {cache: "no-store"});
128 1 : const manifestRaw = (await manifestRequest.json());
129 1 : const manifest = parseReleaseManifest(manifestRaw);
130 : // Set release manifest
131 1 : const end = performance.now();
132 1 : traced.info(`Fetched release manifest`, { "channel": channel, "duration": Math.floor(end - start).toString() }, LOG_CTX);
133 1 : return manifest;
134 1 : } catch (e: any) {
135 1 : const end = performance.now();
136 1 : traced.warn(`Failed to fetch release manifest`, { "channel": channel, "duration": Math.floor(end - start).toString(), "error": stringifyError(e) }, LOG_CTX);
137 1 : throw e;
138 1 : }
139 1 : }
140 :
141 1 : export const WebVersionCheck: React.FC<Props> = (props: Props) => {
142 1 : const logger = useLogger();
143 :
144 1 : const [stableRelease, setStableRelease] = React.useState<Result<ReleaseManifest> | null>(null);
145 1 : const [latestRelease, setLatestRelease] = React.useState<Result<ReleaseManifest> | null>(null);
146 :
147 1 : const check = React.useCallback(async () => {
148 1 : await Promise.all([
149 1 : awaitAndSet(loadReleaseManifest("stable", DASHQL_STABLE_RELEASE_MANIFEST, logger), setStableRelease),
150 1 : awaitAndSet(loadReleaseManifest("latest", DASHQL_LATEST_RELEASE_MANIFEST, logger), setLatestRelease),
151 1 : ]);
152 1 : }, [logger]);
153 1 : const refresh = useVersionCheckRefreshState(check);
154 :
155 1 : return (
156 1 : <VERSION_CHECK_CTX.Provider value={VersionCheckStatusCode.Disabled}>
157 1 : <VERSION_CHECK_REFRESH_CTX.Provider value={refresh}>
158 1 : <INSTALLATION_STATUS_CTX.Provider value={null}>
159 1 : <STABLE_RELEASE_MANIFEST_CTX.Provider value={stableRelease}>
160 1 : <STABLE_UPDATE_MANIFEST_CTX.Provider value={null}>
161 1 : <LATEST_RELEASE_MANIFEST_CTX.Provider value={latestRelease}>
162 1 : <LATEST_UPDATE_MANIFEST_CTX.Provider value={null}>
163 1 : {props.children}
164 1 : </LATEST_UPDATE_MANIFEST_CTX.Provider>
165 1 : </LATEST_RELEASE_MANIFEST_CTX.Provider>
166 1 : </STABLE_UPDATE_MANIFEST_CTX.Provider>
167 1 : </STABLE_RELEASE_MANIFEST_CTX.Provider>
168 1 : </INSTALLATION_STATUS_CTX.Provider>
169 1 : </VERSION_CHECK_REFRESH_CTX.Provider>
170 1 : </VERSION_CHECK_CTX.Provider>
171 : );
172 1 : };
|