@graphql-hive/cli
Version:
A CLI util to manage and control your GraphQL Hive
308 lines • 13.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
const crypto_1 = require("crypto");
const zod_1 = require("zod");
const core_1 = require("@oclif/core");
const base_command_1 = tslib_1.__importDefault(require("../../base-command"));
const gql_1 = require("../../gql");
const graphql_1 = require("../../gql/graphql");
const config_1 = require("../../helpers/config");
const errors_1 = require("../../helpers/errors");
const TargetInput = tslib_1.__importStar(require("../../helpers/target-input"));
class AppCreate extends base_command_1.default {
async run() {
var _a, _b, _c;
const { flags, args } = await this.parse(AppCreate);
const startTime = Date.now();
let endpoint, accessToken;
try {
endpoint = this.ensure({
key: 'registry.endpoint',
args: flags,
defaultValue: config_1.graphqlEndpoint,
env: 'HIVE_REGISTRY',
description: AppCreate.flags['registry.endpoint'].description,
});
}
catch (e) {
throw new errors_1.MissingEndpointError();
}
try {
accessToken = this.ensure({
key: 'registry.accessToken',
args: flags,
env: 'HIVE_TOKEN',
description: AppCreate.flags['registry.accessToken'].description,
});
}
catch (e) {
throw new errors_1.MissingRegistryTokenError();
}
let target = null;
if (flags.target) {
const result = TargetInput.parse(flags.target);
if (result.type === 'error') {
throw new errors_1.InvalidTargetError();
}
target = result.data;
}
const file = args.file;
const contents = this.readJSON(file);
const operations = JSON.parse(contents);
const validationResult = ManifestModel.safeParse(operations);
if (validationResult.success === false) {
throw new errors_1.PersistedOperationsMalformedError(file);
}
// Validate hashes are sha256 and match content (unless v1 format)
if (flags.format !== 'v1') {
const sha256Regex = /^(sha256:)?[a-f0-9]{64}$/i;
const invalidFormatHashes = [];
const mismatchedHashes = [];
for (const [hash, body] of Object.entries(validationResult.data)) {
if (!sha256Regex.test(hash)) {
invalidFormatHashes.push(hash);
}
else {
// Verify hash matches content
const computedHash = (0, crypto_1.createHash)('sha256').update(body).digest('hex');
const providedHash = hash.replace(/^sha256:/i, '').toLowerCase();
if (computedHash !== providedHash) {
mismatchedHashes.push({ hash: providedHash, expected: computedHash });
}
}
}
if (invalidFormatHashes.length > 0) {
const examples = invalidFormatHashes.slice(0, 3).join(', ');
const more = invalidFormatHashes.length > 3 ? ` (and ${invalidFormatHashes.length - 3} more)` : '';
throw new errors_1.APIError(`Invalid hash format detected: ${examples}${more}\n` +
`Hashes must be sha256 (64 hexadecimal characters, optionally prefixed with "sha256:").\n` +
`This is required for safe cross-version document deduplication.\n` +
`Use --format=v1 to bypass this check (disables cross-version deduplication).`);
}
if (mismatchedHashes.length > 0) {
const example = mismatchedHashes[0];
const more = mismatchedHashes.length > 1 ? ` (and ${mismatchedHashes.length - 1} more)` : '';
throw new errors_1.APIError(`Hash does not match document content${more}.\n` +
`Provided: ${example.hash}\n` +
`Expected: ${example.expected}\n` +
`Ensure your manifest uses sha256 hash of the raw document body.`);
}
}
const result = await this.registryApi(endpoint, accessToken).request({
operation: CreateAppDeploymentMutation,
variables: {
input: {
appName: flags['name'],
appVersion: flags['version'],
target,
},
},
});
if (result.createAppDeployment.error) {
throw new errors_1.APIError(result.createAppDeployment.error.message);
}
if (!result.createAppDeployment.ok) {
throw new errors_1.APIError(`Create App failed without providing a reason.`);
}
if (result.createAppDeployment.ok.createdAppDeployment.status !== graphql_1.AppDeploymentStatus.Pending) {
this.log(`App deployment "${flags['name']}@${flags['version']}" is "${result.createAppDeployment.ok.createdAppDeployment.status}". Skip uploading documents...`);
return;
}
const allDocuments = Object.entries(validationResult.data);
const totalDocuments = allDocuments.length;
// Fetch existing hashes for delta upload
let existingHashes = new Set();
if (flags.format !== 'v1') {
if (!target) {
throw new errors_1.APIError('The --target flag is required when using --format=v2 for delta optimization.');
}
const hashesResult = await this.registryApi(endpoint, accessToken).request({
operation: GetExistingDocumentHashesQuery,
variables: {
target,
appName: flags['name'],
},
});
if ((_a = hashesResult.target) === null || _a === void 0 ? void 0 : _a.appDeploymentDocumentHashes.error) {
this.logWarning(`Could not fetch existing document hashes: ${hashesResult.target.appDeploymentDocumentHashes.error.message}. Delta optimization disabled.`);
}
else if ((_c = (_b = hashesResult.target) === null || _b === void 0 ? void 0 : _b.appDeploymentDocumentHashes.ok) === null || _c === void 0 ? void 0 : _c.hashes) {
existingHashes = new Set(hashesResult.target.appDeploymentDocumentHashes.ok.hashes);
if (flags.showTiming) {
this.log(`Found ${existingHashes.size} existing documents (will skip)`);
}
}
else if (!hashesResult.target) {
this.logWarning(`Target not found when fetching existing hashes. Delta optimization disabled.`);
}
}
// Filter out already-existing documents
const newDocuments = allDocuments.filter(([hash]) => !existingHashes.has(hash));
const skippedCount = totalDocuments - newDocuments.length;
if (newDocuments.length === 0) {
this.log(`App deployment "${flags['name']}@${flags['version']}" - all ${totalDocuments} documents already exist. Nothing to upload.`);
this.log(`Note: The deployment is still in "pending" status. Run "hive app:publish --name=${flags['name']} --version=${flags['version']}" to activate it.`);
return;
}
this.log(`App deployment "${flags['name']}@${flags['version']}" is created pending document upload. Uploading ${newDocuments.length} new documents (${skippedCount} already exist)...`);
let buffer = [];
let counter = 0;
const flush = async (force = false) => {
var _a;
if (buffer.length >= 100 || (force && buffer.length > 0)) {
const result = await this.registryApi(endpoint, accessToken).request({
operation: AddDocumentsToAppDeploymentMutation,
variables: {
input: {
target,
appName: flags['name'],
appVersion: flags['version'],
documents: buffer,
format: flags.format === 'v1' ? graphql_1.AppDeploymentFormatType.V1 : graphql_1.AppDeploymentFormatType.V2,
},
},
});
if (result.addDocumentsToAppDeployment.error) {
if (result.addDocumentsToAppDeployment.error.details) {
const affectedOperation = buffer.at(result.addDocumentsToAppDeployment.error.details.index);
const maxCharacters = 40;
if (affectedOperation) {
const truncatedBody = (affectedOperation.body.length > maxCharacters - 3
? affectedOperation.body.substring(0, maxCharacters) + '...'
: affectedOperation.body).replace(/\n/g, '\\n');
this.logWarning(`Failed uploading document: ${result.addDocumentsToAppDeployment.error.details.message}` +
`\nOperation hash: ${affectedOperation === null || affectedOperation === void 0 ? void 0 : affectedOperation.hash}` +
`\nOperation body: ${truncatedBody}`);
}
}
throw new errors_1.APIError(result.addDocumentsToAppDeployment.error.message);
}
if (flags.showTiming && ((_a = result.addDocumentsToAppDeployment.ok) === null || _a === void 0 ? void 0 : _a.timing)) {
const t = result.addDocumentsToAppDeployment.ok.timing;
this.log(` Batch timing: ${t.totalMs}ms total (${t.documentsProcessed} docs, parse: ${t.parsingMs}ms, validate: ${t.validationMs}ms, coords: ${t.coordinateExtractionMs}ms, ch: ${t.clickhouseMs}ms, s3: ${t.s3Ms}ms)`);
}
buffer = [];
// don't bother showing 100% since there's another log line when it's done. And for deployments with just a few docs, showing this progress is unnecessary.
if (counter !== newDocuments.length) {
this.log(`${counter} / ${newDocuments.length} (${Math.round((100.0 * counter) / newDocuments.length)}%) documents uploaded...`);
}
}
};
for (const [hash, body] of newDocuments) {
buffer.push({ hash, body });
counter++;
await flush();
}
await flush(true);
if (flags.showTiming) {
const totalTime = Date.now() - startTime;
this.log(`Total time: ${totalTime}ms`);
}
this.log(`\nApp deployment "${flags['name']}@${flags['version']}" (${counter} new, ${skippedCount} skipped) created.\nActivate it with the "hive app:publish" command.`);
}
}
AppCreate.description = 'create an app deployment';
AppCreate.flags = {
'registry.endpoint': core_1.Flags.string({
description: 'registry endpoint',
}),
'registry.accessToken': core_1.Flags.string({
description: 'registry access token',
}),
name: core_1.Flags.string({
description: 'app name',
required: true,
}),
version: core_1.Flags.string({
description: 'app version',
required: true,
}),
target: core_1.Flags.string({
description: 'The target in which the app deployment will be created.' +
' This can either be a slug following the format "$organizationSlug/$projectSlug/$targetSlug" (e.g "the-guild/graphql-hive/staging")' +
' or an UUID (e.g. "a0f4c605-6541-4350-8cfe-b31f21a4bf80").',
}),
showTiming: core_1.Flags.boolean({
description: 'Show timing breakdown for each batch',
default: false,
}),
format: core_1.Flags.string({
description: 'Storage format version. "v1" (default) uses per-version storage and allows any hash format. "v2" enables cross-version deduplication and requires sha256 hashes.',
default: 'v1',
options: ['v1', 'v2'],
}),
};
AppCreate.args = {
file: core_1.Args.string({
name: 'file',
required: true,
description: 'Path to the persisted operations mapping.',
hidden: false,
}),
};
exports.default = AppCreate;
const ManifestModel = zod_1.z.record(zod_1.z.string());
const CreateAppDeploymentMutation = (0, gql_1.graphql)(/* GraphQL */ `
mutation CreateAppDeployment($input: CreateAppDeploymentInput!) {
createAppDeployment(input: $input) {
ok {
createdAppDeployment {
id
name
version
status
}
}
error {
message
}
}
}
`);
const AddDocumentsToAppDeploymentMutation = (0, gql_1.graphql)(/* GraphQL */ `
mutation AddDocumentsToAppDeployment($input: AddDocumentsToAppDeploymentInput!) {
addDocumentsToAppDeployment(input: $input) {
ok {
appDeployment {
id
name
version
status
}
timing {
totalMs
parsingMs
validationMs
coordinateExtractionMs
clickhouseMs
s3Ms
documentsProcessed
}
}
error {
message
details {
index
message
__typename
}
}
}
}
`);
const GetExistingDocumentHashesQuery = (0, gql_1.graphql)(/* GraphQL */ `
query GetExistingDocumentHashes($target: TargetReferenceInput!, $appName: String!) {
target(reference: $target) {
appDeploymentDocumentHashes(appName: $appName) {
ok {
hashes
}
error {
message
}
}
}
}
`);
//# sourceMappingURL=create.js.map