From 59a1ed88db80c5d0337b64fedda7682d7adaa14e Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 1 Apr 2023 19:33:12 -0700 Subject: [PATCH] First stab at adopting Grats! --- .../api/graphql/ModernSkinsConnection.ts | 10 +- .../api/graphql/SkinsConnection.ts | 17 +- .../api/graphql/TweetsConnection.ts | 23 +- .../api/graphql/grats-schema.graphql | 257 ++++++++++++++++++ .../graphql/resolvers/ArchiveFileResolver.ts | 30 +- .../graphql/resolvers/ClassicSkinResolver.ts | 78 +++++- .../graphql/resolvers/CommonSkinResolver.ts | 89 +++++- .../resolvers/DatabaseStatisticsResolver.ts | 32 ++- .../resolvers/InternetArchiveItemResolver.ts | 20 +- .../graphql/resolvers/ModernSkinResolver.ts | 89 +++++- .../api/graphql/resolvers/MutationResolver.ts | 60 +++- .../api/graphql/resolvers/NodeResolver.ts | 15 +- .../api/graphql/resolvers/ReviewResolver.ts | 30 +- .../api/graphql/resolvers/RootResolver.ts | 210 ++++++++++++-- .../api/graphql/resolvers/SkinResolver.ts | 11 +- .../api/graphql/resolvers/TweetResolver.ts | 15 +- .../api/graphql/resolvers/UserResolver.ts | 4 +- .../skin-database/api/graphql/schema.graphql | 10 +- packages/skin-database/package.json | 5 +- packages/skin-database/tsconfig.json | 3 + packages/skin-database/types.ts | 5 +- yarn.lock | 33 +++ 22 files changed, 940 insertions(+), 106 deletions(-) create mode 100644 packages/skin-database/api/graphql/grats-schema.graphql diff --git a/packages/skin-database/api/graphql/ModernSkinsConnection.ts b/packages/skin-database/api/graphql/ModernSkinsConnection.ts index 8fc38d7b..b9ee85f7 100644 --- a/packages/skin-database/api/graphql/ModernSkinsConnection.ts +++ b/packages/skin-database/api/graphql/ModernSkinsConnection.ts @@ -1,7 +1,9 @@ +import { Int } from "grats"; import SkinModel from "../../data/SkinModel"; import { knex } from "../../db"; import ModernSkinResolver from "./resolvers/ModernSkinResolver"; +/** @gqlType */ export default class ModernSkinsConnection { _first: number; _offset: number; @@ -14,12 +16,14 @@ export default class ModernSkinsConnection { return query; } - async count() { + /** @gqlField */ + async count(): Promise { const count = await this._getQuery().count("*", { as: "count" }); - return count[0].count; + return Number(count[0].count); } - async nodes(_args, ctx) { + /** @gqlField */ + async nodes(_args: never, ctx): Promise { const skins = await this._getQuery() .select() .limit(this._first) diff --git a/packages/skin-database/api/graphql/SkinsConnection.ts b/packages/skin-database/api/graphql/SkinsConnection.ts index b8f1797d..53a840c0 100644 --- a/packages/skin-database/api/graphql/SkinsConnection.ts +++ b/packages/skin-database/api/graphql/SkinsConnection.ts @@ -3,6 +3,8 @@ import * as Skins from "../../data/skins"; import { knex } from "../../db"; import SkinResolver from "./resolvers/SkinResolver"; import LRU from "lru-cache"; +import { Int } from "grats"; +import { ISkin } from "./resolvers/CommonSkinResolver"; const options = { max: 100, @@ -34,12 +36,13 @@ async function getSkinMuseumPageFromCache(first: number, offset: number) { return skins; } +/** @gqlType */ export default class SkinsConnection { _first: number; _offset: number; - _sort: string; - _filter: string; - constructor(first: number, offset: number, sort: string, filter: string) { + _sort?: string; + _filter?: string; + constructor(first: number, offset: number, sort?: string, filter?: string) { this._first = first; this._offset = offset; this._filter = filter; @@ -73,16 +76,18 @@ export default class SkinsConnection { return query; } - async count() { + /** @gqlField */ + async count(): Promise { if (this._sort === "MUSEUM") { // This is the common case, so serve it from cache. return getMuseumSkinCountFromCache(); } const count = await this._getQuery().count("*", { as: "count" }); - return count[0].count; + return Number(count[0].count); } - async nodes(args, ctx) { + /** @gqlField */ + async nodes(args: never, ctx): Promise { if (this._sort === "MUSEUM") { if (this._filter) { throw new Error( diff --git a/packages/skin-database/api/graphql/TweetsConnection.ts b/packages/skin-database/api/graphql/TweetsConnection.ts index fff742e6..ee370042 100644 --- a/packages/skin-database/api/graphql/TweetsConnection.ts +++ b/packages/skin-database/api/graphql/TweetsConnection.ts @@ -1,12 +1,17 @@ +import { Int } from "grats"; import TweetModel from "../../data/TweetModel"; import { knex } from "../../db"; import TweetResolver from "./resolvers/TweetResolver"; +/** @gqlEnum */ +export type TweetsSortOption = "LIKES" | "RETWEETS"; + +/** @gqlType */ export default class TweetsConnection { _first: number; _offset: number; - _sort: string; - constructor(first: number, offset: number, sort: string) { + _sort?: TweetsSortOption; + constructor(first: number, offset: number, sort?: TweetsSortOption) { this._first = first; this._offset = offset; this._sort = sort; @@ -23,12 +28,20 @@ export default class TweetsConnection { return query; } - async count() { + /** + * The total number of tweets + * @gqlField + */ + async count(): Promise { const count = await this._getQuery().count("*", { as: "count" }); - return count[0].count; + return Number(count[0].count); } - async nodes(args, ctx) { + /** + * The list of tweets + * @gqlField + */ + async nodes(args: never, ctx): Promise { const tweets = await this._getQuery() .select() .limit(this._first) diff --git a/packages/skin-database/api/graphql/grats-schema.graphql b/packages/skin-database/api/graphql/grats-schema.graphql new file mode 100644 index 00000000..c75b2e8e --- /dev/null +++ b/packages/skin-database/api/graphql/grats-schema.graphql @@ -0,0 +1,257 @@ +schema { + query: Query + mutation: Mutation +} + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +directive @methodName(name: String!) on FIELD_DEFINITION + +type ArchiveFile { + date: String + file_md5: String + filename: String + is_directory: Boolean + size: Int + skin: Skin + text_content: String + url: String +} + +type ClassicSkin implements Node & Skin { + archive_files: [ArchiveFile!] + average_color: String + download_url: String + filename(normalize_extension: Boolean = false): String + has_media_library: Boolean + id: ID! + internet_archive_item: InternetArchiveItem + last_algolia_index_update_date: String + md5: String + museum_url: String + nsfw: Boolean + readme_text: String + reviews: [Review!] + screenshot_url: String + transparent_pixels: Int + tweeted: Boolean + tweets: [Tweet!] + webamp_url: String +} + +type DatabaseStatistics { + approved_skins_count: Int + nsfw_skins_count: Int + rejected_skins_count: Int + tweetable_skins_count: Int + tweeted_skins_count: Int + unique_classic_skins_count: Int + unreviewed_skins_count: Int + uploads_in_error_state_count: Int + uploads_pending_processing_count: Int + web_uploads_count: Int +} + +type InternetArchiveItem { + identifier: String + last_metadata_scrape_date_UNSTABLE: String + metadata_url: String + raw_metadata_json: String + skin: Skin + url: String +} + +type ModernSkin implements Node & Skin { + archive_files: [ArchiveFile!] + average_color: String + download_url: String + filename(normalize_extension: Boolean = false): String + id: ID! + internet_archive_item: InternetArchiveItem + md5: String + museum_url: String + nsfw: Boolean + readme_text: String + reviews: [Review!] + screenshot_url: String + tweeted: Boolean + tweets: [Tweet!] + webamp_url: String +} + +type ModernSkinsConnection { + count: Int + nodes: [ModernSkin!] +} + +type Mutation { + approve_skin(md5: String!): Boolean + mark_skin_nsfw(md5: String!): Boolean + reject_skin(md5: String!): Boolean + request_nsfw_review_for_skin(md5: String!): Boolean + send_feedback(email: String, message: String, url: String): Boolean + upload: UploadMutations +} + +""" +A globally unique object. The `id` here is intended only for use within +GraphQL. +https://graphql.org/learn/global-object-identification/ +""" +interface Node { + id: ID! +} + +type Query { + """ + Fetch archive file by it's MD5 hash + Get information about a file found within a skin's wsz/wal/zip archive. + """ + fetch_archive_file_by_md5(md5: String!): ArchiveFile + """ + Get an archive.org item by its identifier. You can find this in the URL: + https://archive.org/details// + """ + fetch_internet_archive_item_by_identifier(identifier: String!): InternetArchiveItem + """Get a skin by its MD5 hash""" + fetch_skin_by_md5(md5: String!): Skin + """Get a tweet by its URL""" + fetch_tweet_by_url(url: String!): Tweet + """The currently authenticated user, if any.""" + me: User + modern_skins(first: Int = 10, offset: Int = 0): ModernSkinsConnection + """ + Get a globally unique object by its ID. + https://graphql.org/learn/global-object-identification/ + """ + node(id: ID!): Node + """ + Search the database using the Algolia search index used by the Museum. + + Useful for locating a particular skin. + """ + search_skins(first: Int = 10, offset: Int = 0, query: String!): [Skin!] + skin_to_review: Skin + skins(filter: SkinsFilterOption, first: Int = 10, offset: Int = 0, sort: SkinsSortOption): SkinsConnection + statistics: DatabaseStatistics + """Tweets tweeted by @winampskins""" + tweets(first: Int = 10, offset: Int = 0, sort: TweetsSortOption): TweetsConnection + upload_statuses(ids: [String!]!): [SkinUpload!] + """Get the status of a batch of uploads by md5s""" + upload_statuses_by_md5(md5s: [String!]!): [SkinUpload!] +} + +enum Rating { + APPROVED + NSFW + REJECTED +} + +""" +A review of a skin. Done either on the Museum's Tinder-style +reivew page, or via the Discord bot. +""" +type Review { + """The rating that the user gave the skin""" + rating: Rating + """ + The user who made the review (if known). **Note:** In the early days we + didn't track this, so many will be null. + """ + reviewer: String + """The skin that was reviewed""" + skin: Skin +} + +interface Skin { + archive_files: [ArchiveFile!] + average_color: String @deprecated(reason: "Needed for migration to new skin model") + download_url: String + filename(normalize_extension: Boolean = false): String + id: ID! + internet_archive_item: InternetArchiveItem + md5: String + museum_url: String @deprecated(reason: "Needed for migration to new skin model") + nsfw: Boolean @deprecated(reason: "Needed for migration to new skin model") + readme_text: String @deprecated(reason: "Needed for migration to new skin model") + reviews: [Review!] + screenshot_url: String @deprecated(reason: "Needed for migration to new skin model") + tweeted: Boolean + tweets: [Tweet!] + webamp_url: String @deprecated(reason: "Needed for migration to new skin model") +} + +type SkinUpload { + id: String + skin: Skin + status: SkinUploadStatus + upload_md5: String +} + +""" +The current status of a pending upload. +**Note:** Expect more values here as we try to be more transparent about +the status of a pending uploads. +""" +enum SkinUploadStatus { + ARCHIVED + DELAYED + ERRORED + UPLOAD_REPORTED + URL_REQUESTED +} + +type SkinsConnection { + count: Int + nodes: [Skin!] +} + +enum SkinsFilterOption { + APPROVED + NSFW + REJECTED + TWEETED +} + +enum SkinsSortOption { + MUSEUM +} + +type Tweet { + likes: Int + retweets: Int + skin: Skin + url: String +} + +type TweetsConnection { + """The total number of tweets""" + count: Int + """The list of tweets""" + nodes: [Tweet!] +} + +enum TweetsSortOption { + LIKES + RETWEETS +} + +type UploadMutations { + get_upload_urls(files: [UploadUrlRequest!]!): [UploadUrl!] + report_skin_uploaded(id: String!, md5: String!): Boolean +} + +type UploadUrl { + id: String + md5: String + url: String +} + +input UploadUrlRequest { + filename: String! + md5: String! +} + +type User { + username: String +} \ No newline at end of file diff --git a/packages/skin-database/api/graphql/resolvers/ArchiveFileResolver.ts b/packages/skin-database/api/graphql/resolvers/ArchiveFileResolver.ts index 5d83d822..8d8291f7 100644 --- a/packages/skin-database/api/graphql/resolvers/ArchiveFileResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/ArchiveFileResolver.ts @@ -1,35 +1,49 @@ +import { Int } from "grats"; import ArchiveFileModel from "../../../data/ArchiveFileModel"; import SkinModel from "../../../data/SkinModel"; +import { ISkin } from "./CommonSkinResolver"; import SkinResolver from "./SkinResolver"; +/** @gqlType ArchiveFile */ export default class ArchiveFileResolver { _model: ArchiveFileModel; constructor(model: ArchiveFileModel) { this._model = model; } - filename() { + + /** @gqlField */ + filename(): string { return this._model.getFileName(); } - url() { + /** @gqlField */ + url(): string { return this._model.getUrl(); } - file_md5() { + /** @gqlField */ + file_md5(): string { return this._model.getFileMd5(); } - size() { + /** @gqlField */ + size(): Promise { return this._model.getFileSize(); } - text_content() { + /** @gqlField */ + text_content(): Promise { return this._model.getTextContent(); } - is_directory() { + /** @gqlField */ + is_directory(): boolean { return this._model.getIsDirectory(); } - async skin(_, { ctx }) { + + /** @gqlField */ + async skin(_: never, { ctx }): Promise { const model = await SkinModel.fromMd5Assert(ctx, this._model.getMd5()); return SkinResolver.fromModel(model); } - date() { + + /** @gqlField */ + date(): string { return this._model.getFileDate().toISOString(); } } diff --git a/packages/skin-database/api/graphql/resolvers/ClassicSkinResolver.ts b/packages/skin-database/api/graphql/resolvers/ClassicSkinResolver.ts index 85420d46..c11cf9b1 100644 --- a/packages/skin-database/api/graphql/resolvers/ClassicSkinResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/ClassicSkinResolver.ts @@ -1,49 +1,100 @@ -import CommonSkinResolver from "./CommonSkinResolver"; +import CommonSkinResolver, { ISkin } from "./CommonSkinResolver"; import { NodeResolver, toId } from "./NodeResolver"; import ReviewResolver from "./ReviewResolver"; import path from "path"; +import { ID, Int } from "grats"; +import TweetResolver from "./TweetResolver"; +import ArchiveFileResolver from "./ArchiveFileResolver"; +import InternetArchiveItemResolver from "./InternetArchiveItemResolver"; +/** @gqlType ClassicSkin */ export default class ClassicSkinResolver extends CommonSkinResolver - implements NodeResolver + implements NodeResolver, ISkin { __typename = "ClassicSkin"; - async id() { + /** + * @gqlField + * @killsParentOnException + */ + id(): ID { return toId(this.__typename, this.md5()); } - async filename(normalize_extension = false) { + /** @gqlField */ + async filename({ + normalize_extension = false, + }: { + normalize_extension?: boolean; + }): Promise { const filename = await this._model.getFileName(); if (normalize_extension) { return path.parse(filename).name + ".wsz"; } return filename; } - museum_url() { + + /** @gqlField */ + md5(): string { + return super.md5(); + } + /** @gqlField */ + download_url(): string { + return super.download_url(); + } + /** @gqlField */ + tweeted(): Promise { + return super.tweeted(); + } + + /** @gqlField */ + tweets(): Promise { + return super.tweets(); + } + + /** @gqlField */ + archive_files(): Promise { + return super.archive_files(); + } + + /** @gqlField */ + internet_archive_item(): Promise { + return super.internet_archive_item(); + } + /** @gqlField */ + museum_url(): string { return this._model.getMuseumUrl(); } - webamp_url() { + /** @gqlField */ + webamp_url(): string { return this._model.getWebampUrl(); } - screenshot_url() { + /** @gqlField */ + screenshot_url(): string { return this._model.getScreenshotUrl(); } - readme_text() { + /** @gqlField */ + readme_text(): Promise { return this._model.getReadme(); } - nsfw() { + /** @gqlField */ + nsfw(): Promise { return this._model.getIsNsfw(); } - average_color() { + /** @gqlField */ + average_color(): string { return this._model.getAverageColor(); } + /** @gqlField */ has_media_library(): Promise { return this._model.hasMediaLibrary(); } - async reviews() { + /** @gqlField */ + async reviews(): Promise { const reviews = await this._model.getReviews(); return reviews.map((row) => new ReviewResolver(row)); } - async last_algolia_index_update_date() { + /** @gqlField */ + async last_algolia_index_update_date(): Promise { const updates = await this._model.getAlgoliaIndexUpdates(1); if (updates.length < 1) { return null; @@ -51,7 +102,8 @@ export default class ClassicSkinResolver const update = updates[0]; return new Date(update.update_timestamp * 1000).toISOString(); } - transparent_pixels(): Promise { + /** @gqlField */ + transparent_pixels(): Promise { return this._model.transparentPixels(); } } diff --git a/packages/skin-database/api/graphql/resolvers/CommonSkinResolver.ts b/packages/skin-database/api/graphql/resolvers/CommonSkinResolver.ts index 1bd41584..5528c89b 100644 --- a/packages/skin-database/api/graphql/resolvers/CommonSkinResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/CommonSkinResolver.ts @@ -1,33 +1,108 @@ +import { ID } from "grats"; import SkinModel from "../../../data/SkinModel"; import ArchiveFileResolver from "./ArchiveFileResolver"; import InternetArchiveItemResolver from "./InternetArchiveItemResolver"; import ReviewResolver from "./ReviewResolver"; import TweetResolver from "./TweetResolver"; +/** @gqlInterface Skin */ +export interface ISkin { + __typename: string; + /** + * @gqlField + * @killsParentOnException + */ + id(): ID; + + /** @gqlField */ + filename({ + normalize_extension = false, + }: { + normalize_extension?: boolean; + }): Promise; + + /** @gqlField */ + md5(): string; + + /** @gqlField */ + download_url(): string; + + /** @gqlField */ + tweeted(): Promise; + + /** @gqlField */ + tweets(): Promise; + + /** @gqlField */ + archive_files(): Promise; + + /** @gqlField */ + internet_archive_item(): Promise; + + /** @gqlField */ + reviews(): Promise; + + /** + * @gqlField + * @deprecated Needed for migration to new skin model + */ + museum_url(): string | null; + + /** + * @gqlField + * @deprecated Needed for migration to new skin model + */ + webamp_url(): string | null; + + /** + * @gqlField + * @deprecated Needed for migration to new skin model + */ + screenshot_url(): string | null; + + /** + * @gqlField + * @deprecated Needed for migration to new skin model + */ + readme_text(): Promise; + + /** + * @gqlField + * @deprecated Needed for migration to new skin model + */ + nsfw(): Promise; + + /** + * @gqlField + * @deprecated Needed for migration to new skin model + */ + average_color(): string | null; +} + export default class CommonSkinResolver { _model: SkinModel; constructor(model: SkinModel) { this._model = model; } - md5() { + md5(): string { return this._model.getMd5(); } - download_url() { + download_url(): string { return this._model.getSkinUrl(); } - tweeted() { + tweeted(): Promise { return this._model.tweeted(); } - async tweets() { + async tweets(): Promise { const tweets = await this._model.getTweets(); return tweets.map((tweetModel) => new TweetResolver(tweetModel)); } - async archive_files() { + async archive_files(): Promise { const files = await this._model.getArchiveFiles(); return files.map((file) => new ArchiveFileResolver(file)); } - async internet_archive_item() { + async internet_archive_item(): Promise { const item = await this._model.getIaItem(); if (item == null) { return null; @@ -35,7 +110,7 @@ export default class CommonSkinResolver { return new InternetArchiveItemResolver(item); } - async reviews() { + async reviews(): Promise { const reviews = await this._model.getReviews(); return reviews.map((row) => new ReviewResolver(row)); } diff --git a/packages/skin-database/api/graphql/resolvers/DatabaseStatisticsResolver.ts b/packages/skin-database/api/graphql/resolvers/DatabaseStatisticsResolver.ts index 7d30571d..d09e60ca 100644 --- a/packages/skin-database/api/graphql/resolvers/DatabaseStatisticsResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/DatabaseStatisticsResolver.ts @@ -1,34 +1,46 @@ +import { Int } from "grats"; import * as Skins from "../../../data/skins"; +/** @gqlType DatabaseStatistics */ export default class DatabaseStatisticsResolver { - unique_classic_skins_count(): Promise { + /** @gqlField */ + unique_classic_skins_count(): Promise { return Skins.getClassicSkinCount(); } - tweeted_skins_count(): Promise { + /** @gqlField */ + tweeted_skins_count(): Promise { return Skins.getTweetedSkinCount(); } - approved_skins_count(): Promise { + /** @gqlField */ + approved_skins_count(): Promise { return Skins.getApprovedSkinCount(); } - rejected_skins_count(): Promise { + /** @gqlField */ + rejected_skins_count(): Promise { return Skins.getRejectedSkinCount(); } - nsfw_skins_count(): Promise { + /** @gqlField */ + nsfw_skins_count(): Promise { return Skins.getNsfwSkinCount(); } - unreviewed_skins_count(): Promise { + /** @gqlField */ + unreviewed_skins_count(): Promise { return Skins.getUnreviewedSkinCount(); } - tweetable_skins_count(): Promise { + /** @gqlField */ + tweetable_skins_count(): Promise { return Skins.getTweetableSkinCount(); } - uploads_pending_processing_count(): Promise { + /** @gqlField */ + uploads_pending_processing_count(): Promise { return Skins.getUploadsAwaitingProcessingCount(); } - uploads_in_error_state_count(): Promise { + /** @gqlField */ + uploads_in_error_state_count(): Promise { return Skins.getUploadsErroredCount(); } - web_uploads_count(): Promise { + /** @gqlField */ + web_uploads_count(): Promise { return Skins.getWebUploadsCount(); } } diff --git a/packages/skin-database/api/graphql/resolvers/InternetArchiveItemResolver.ts b/packages/skin-database/api/graphql/resolvers/InternetArchiveItemResolver.ts index a31ed025..4d49807d 100644 --- a/packages/skin-database/api/graphql/resolvers/InternetArchiveItemResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/InternetArchiveItemResolver.ts @@ -1,29 +1,37 @@ import IaItemModel from "../../../data/IaItemModel"; +import { ISkin } from "./CommonSkinResolver"; import SkinResolver from "./SkinResolver"; +/** @gqlType InternetArchiveItem */ export default class InternetArchiveItemResolver { _model: IaItemModel; constructor(model: IaItemModel) { this._model = model; } - identifier() { + /** @gqlField */ + identifier(): string { return this._model.getIdentifier(); } - url() { + /** @gqlField */ + url(): string { return this._model.getUrl(); } - metadata_url() { + /** @gqlField */ + metadata_url(): string { return this._model.getMetadataUrl(); } - last_metadata_scrape_date_UNSTABLE() { + /** @gqlField */ + last_metadata_scrape_date_UNSTABLE(): string | null { return this._model.getMetadataTimestamp(); } - raw_metadata_json() { + /** @gqlField */ + raw_metadata_json(): string | null { return this._model.getMetadataJSON(); } - async skin() { + /** @gqlField */ + async skin(): Promise { const skin = await this._model.getSkin(); if (skin == null) { return null; diff --git a/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts b/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts index e05e24aa..47bd9e04 100644 --- a/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts @@ -1,22 +1,99 @@ import SkinModel from "../../../data/SkinModel"; -import CommonSkinResolver from "./CommonSkinResolver"; +import CommonSkinResolver, { ISkin } from "./CommonSkinResolver"; import { NodeResolver, toId } from "./NodeResolver"; import path from "path"; +import { ID } from "grats"; +import ReviewResolver from "./ReviewResolver"; +import InternetArchiveItemResolver from "./InternetArchiveItemResolver"; +import ArchiveFileResolver from "./ArchiveFileResolver"; +import TweetResolver from "./TweetResolver"; +/** @gqlType ModernSkin */ export default class ModernSkinResolver extends CommonSkinResolver - implements NodeResolver + implements NodeResolver, ISkin { _model: SkinModel; __typename = "ModernSkin"; - async id() { - return toId(this.__typename, this.md5()); - } - async filename(normalize_extension = false) { + /** @gqlField */ + async filename({ + normalize_extension = false, + }: { + normalize_extension?: boolean; + }): Promise { const filename = await this._model.getFileName(); if (normalize_extension) { return path.parse(filename).name + ".wal"; } return filename; } + + /* TODO: Get all of these from the parent class/interface */ + + /** + * @gqlField + * @killsParentOnException + */ + id(): ID { + return toId(this.__typename, this.md5()); + } + + /** @gqlField */ + md5(): string { + return super.md5(); + } + + /** @gqlField */ + download_url(): string { + return super.download_url(); + } + /** @gqlField */ + tweeted(): Promise { + return super.tweeted(); + } + /** @gqlField */ + async tweets(): Promise { + return super.tweets(); + } + + /** @gqlField */ + async archive_files(): Promise { + return super.archive_files(); + } + + /** @gqlField */ + async internet_archive_item(): Promise { + return super.internet_archive_item(); + } + + /** @gqlField */ + async reviews(): Promise { + return super.reviews(); + } + + /** @gqlField */ + museum_url(): string | null { + return null; + } + /** @gqlField */ + webamp_url(): string | null { + return null; + } + + /** @gqlField */ + screenshot_url(): string | null { + return null; + } + /** @gqlField */ + async readme_text(): Promise { + return null; + } + /** @gqlField */ + async nsfw(): Promise { + return null; + } + /** @gqlField */ + average_color(): string | null { + return null; + } } diff --git a/packages/skin-database/api/graphql/resolvers/MutationResolver.ts b/packages/skin-database/api/graphql/resolvers/MutationResolver.ts index 4aa12e57..da727fce 100644 --- a/packages/skin-database/api/graphql/resolvers/MutationResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/MutationResolver.ts @@ -5,11 +5,24 @@ import * as Skins from "../../../data/skins"; import { processUserUploads } from "../../processUserUploads"; // We don't use a resolver here, just return the value directly. -type UploadUrl = { id: string; url: string; md5: string }; +/** @gqlType */ +type UploadUrl = { + /** @gqlField */ + id: string; + /** @gqlField */ + url: string; + /** @gqlField */ + md5: string; +}; +/** @gqlInput */ +type UploadUrlRequest = { filename: string; md5: string }; + +/** @gqlType UploadMutations */ class UploadMutationResolver { + /** @gqlField */ async get_upload_urls( - { files }: { files: { filename: string; md5: string }[] }, + { files }: { files: UploadUrlRequest[] }, { ctx } ): Promise { const missing: UploadUrl[] = []; @@ -28,7 +41,11 @@ class UploadMutationResolver { return missing; } - async report_skin_uploaded({ id, md5 }, req) { + /** @gqlField */ + async report_skin_uploaded( + { id, md5 }: { id: string; md5: string }, + req + ): Promise { // TODO: Validate md5 and id; await Skins.recordUserUploadComplete(md5, id); // Don't await, just kick off the task. @@ -47,11 +64,17 @@ function requireAuthed(handler) { }; } +/** @gqlType Mutation */ export default class MutationResolver { - async upload() { + /** @gqlField */ + async upload(): Promise { return new UploadMutationResolver(); } - async send_feedback({ message, email, url }, req) { + /** @gqlField */ + async send_feedback( + { message, email, url }: { message?: string; email?: string; url?: string }, + req + ): Promise { req.notify({ type: "GOT_FEEDBACK", url, @@ -61,7 +84,12 @@ export default class MutationResolver { return true; } - reject_skin = requireAuthed(async ({ md5 }, req) => { + /** @gqlField */ + reject_skin(args: { md5: string }, req): Promise { + return this._reject_skin(args, req); + } + + _reject_skin = requireAuthed(async ({ md5 }, req) => { req.log(`Rejecting skin with hash "${md5}"`); const skin = await SkinModel.fromMd5Assert(req.ctx, md5); if (skin == null) { @@ -72,7 +100,12 @@ export default class MutationResolver { return true; }); - approve_skin = requireAuthed(async ({ md5 }, req) => { + /** @gqlField */ + approve_skin(args: { md5: string }, req): Promise { + return this._approve_skin(args, req); + } + + _approve_skin = requireAuthed(async ({ md5 }, req) => { req.log(`Approving skin with hash "${md5}"`); const skin = await SkinModel.fromMd5(req.ctx, md5); if (skin == null) { @@ -83,7 +116,12 @@ export default class MutationResolver { return true; }); - mark_skin_nsfw = requireAuthed(async ({ md5 }, req) => { + /** @gqlField */ + mark_skin_nsfw(args: { md5: string }, req): Promise { + return this._mark_skin_nsfw(args, req); + } + + _mark_skin_nsfw = requireAuthed(async ({ md5 }, req) => { req.log(`Approving skin with hash "${md5}"`); const skin = await SkinModel.fromMd5(req.ctx, md5); if (skin == null) { @@ -94,7 +132,11 @@ export default class MutationResolver { return true; }); - async request_nsfw_review_for_skin({ md5 }, req) { + /** @gqlField */ + async request_nsfw_review_for_skin( + { md5 }: { md5: string }, + req + ): Promise { req.log(`Reporting skin with hash "${md5}"`); // Blow up if there is no skin with this hash await SkinModel.fromMd5Assert(req.ctx, md5); diff --git a/packages/skin-database/api/graphql/resolvers/NodeResolver.ts b/packages/skin-database/api/graphql/resolvers/NodeResolver.ts index b3055334..859d1508 100644 --- a/packages/skin-database/api/graphql/resolvers/NodeResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/NodeResolver.ts @@ -1,5 +1,18 @@ +import { ID } from "grats"; + +/** + * A globally unique object. The `id` here is intended only for use within + * GraphQL. + * https://graphql.org/learn/global-object-identification/ + * + * @gqlInterface Node + */ export interface NodeResolver { - id({ id }): Promise; + /** + * @gqlField + * @killsParentOnException + */ + id(): ID; __typename: string; } diff --git a/packages/skin-database/api/graphql/resolvers/ReviewResolver.ts b/packages/skin-database/api/graphql/resolvers/ReviewResolver.ts index 6447c492..55ea12e5 100644 --- a/packages/skin-database/api/graphql/resolvers/ReviewResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/ReviewResolver.ts @@ -1,14 +1,38 @@ -import { ReviewRow } from "../../../types"; +import { Rating, ReviewRow } from "../../../types"; +import { ISkin } from "./CommonSkinResolver"; +import SkinResolver from "./SkinResolver"; +/** + * A review of a skin. Done either on the Museum's Tinder-style + * reivew page, or via the Discord bot. + * @gqlType Review */ export default class ReviewResolver { _model: ReviewRow; constructor(model: ReviewRow) { this._model = model; } - reviewer() { + + /** + * The skin that was reviewed + * @gqlField + */ + skin(args: never, { ctx }): Promise { + return SkinResolver.fromMd5(ctx, this._model.skin_md5); + } + + /** + * The user who made the review (if known). **Note:** In the early days we + * didn't track this, so many will be null. + * @gqlField + */ + reviewer(): string { return this._model.reviewer; } - rating() { + /** + * The rating that the user gave the skin + * @gqlField + */ + rating(): Rating { return this._model.review; } } diff --git a/packages/skin-database/api/graphql/resolvers/RootResolver.ts b/packages/skin-database/api/graphql/resolvers/RootResolver.ts index af10ce43..87e6687e 100644 --- a/packages/skin-database/api/graphql/resolvers/RootResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/RootResolver.ts @@ -5,9 +5,10 @@ import SkinResolver from "../resolvers/SkinResolver"; import TweetResolver from "../resolvers/TweetResolver"; import UserResolver from "../resolvers/UserResolver"; import SkinsConnection from "../SkinsConnection"; -import TweetsConnection from "../TweetsConnection"; +import TweetsConnection, { TweetsSortOption } from "../TweetsConnection"; import InternetArchiveItemResolver from "./InternetArchiveItemResolver"; import * as Skins from "../../../data/skins"; +import { ID, Int } from "grats"; import algoliasearch from "algoliasearch"; import MutationResolver from "./MutationResolver"; @@ -15,16 +16,23 @@ import { knex } from "../../../db"; import ArchiveFileModel from "../../../data/ArchiveFileModel"; import ArchiveFileResolver from "./ArchiveFileResolver"; import DatabaseStatisticsResolver from "./DatabaseStatisticsResolver"; -import { fromId } from "./NodeResolver"; +import { fromId, NodeResolver } from "./NodeResolver"; import ModernSkinsConnection from "../ModernSkinsConnection"; import ModernSkinResolver from "./ModernSkinResolver"; +import { ISkin } from "./CommonSkinResolver"; // These keys are already in the web client, so they are not secret at all. const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51"); const index = client.initIndex("Skins"); +/** @gqlType Query */ class RootResolver extends MutationResolver { - async node({ id }, { ctx }) { + /** + * Get a globally unique object by its ID. + * https://graphql.org/learn/global-object-identification/ + * @gqlField + */ + async node({ id }: { id: ID }, { ctx }): Promise { const { graphqlType, id: localId } = fromId(id); // TODO Use typeResolver switch (graphqlType) { @@ -39,7 +47,15 @@ class RootResolver extends MutationResolver { } return null; } - async fetch_skin_by_md5({ md5 }, { ctx }) { + + /** + * Get a skin by its MD5 hash + * @gqlField + */ + async fetch_skin_by_md5( + { md5 }: { md5: string }, + { ctx } + ): Promise { const skin = await SkinModel.fromMd5(ctx, md5); if (skin == null) { return null; @@ -50,21 +66,47 @@ class RootResolver extends MutationResolver { return SkinResolver.fromModel(skin); } } - async fetch_tweet_by_url({ url }, { ctx }) { + + /** + * Get a tweet by its URL + * @gqlField + */ + async fetch_tweet_by_url( + { url }: { url: string }, + { ctx } + ): Promise { const tweet = await TweetModel.fromAnything(ctx, url); if (tweet == null) { return null; } return new TweetResolver(tweet); } - async fetch_internet_archive_item_by_identifier({ identifier }, { ctx }) { + + /** + * Get an archive.org item by its identifier. You can find this in the URL: + * https://archive.org/details// + * @gqlField + */ + async fetch_internet_archive_item_by_identifier( + { identifier }: { identifier: string }, + { ctx } + ): Promise { const iaItem = await IaItemModel.fromIdentifier(ctx, identifier); if (iaItem == null) { return null; } return new InternetArchiveItemResolver(iaItem); } - async fetch_archive_file_by_md5({ md5 }, { ctx }) { + + /** + * Fetch archive file by it's MD5 hash + * Get information about a file found within a skin's wsz/wal/zip archive. + * @gqlField + */ + async fetch_archive_file_by_md5( + { md5 }: { md5: string }, + { ctx } + ): Promise { const archiveFile = await ArchiveFileModel.fromFileMd5(ctx, md5); if (archiveFile == null) { return null; @@ -72,7 +114,20 @@ class RootResolver extends MutationResolver { return new ArchiveFileResolver(archiveFile); } - async search_skins({ query, first, offset }, { ctx }) { + /** + * Search the database using the Algolia search index used by the Museum. + * + * Useful for locating a particular skin. + * @gqlField + */ + async search_skins( + { + query, + first = 10, + offset = 0, + }: { query: string; first?: Int; offset?: Int }, + { ctx } + ): Promise { if (first > 1000) { throw new Error("Can only query 1000 records via search."); } @@ -91,21 +146,40 @@ class RootResolver extends MutationResolver { ); } - async skins({ first, offset, sort, filter }) { + /** @gqlField */ + skins({ + first = 10, + offset = 0, + sort, + filter, + }: { + first?: Int; + offset?: Int; + sort?: SkinsSortOption; + filter?: SkinsFilterOption; + }): SkinsConnection { if (first > 1000) { throw new Error("Maximum limit is 1000"); } return new SkinsConnection(first, offset, sort, filter); } - async modern_skins({ first, offset }) { + /** @gqlField */ + async modern_skins({ + first = 10, + offset = 0, + }: { + first?: Int; + offset?: Int; + }): Promise { if (first > 1000) { throw new Error("Maximum limit is 1000"); } return new ModernSkinsConnection(first, offset); } - async skin_to_review(_args, { ctx }) { + /** @gqlField */ + async skin_to_review(_args: never, { ctx }): Promise { if (!ctx.authed()) { return null; } @@ -113,20 +187,50 @@ class RootResolver extends MutationResolver { const model = await SkinModel.fromMd5Assert(ctx, md5); return SkinResolver.fromModel(model); } - async tweets({ first, offset, sort }) { + + /** + * Tweets tweeted by @winampskins + * @gqlField + */ + async tweets({ + first = 10, + offset = 0, + sort, + }: { + first?: Int; + offset?: Int; + sort?: TweetsSortOption; + }): Promise { if (first > 1000) { throw new Error("Maximum limit is 1000"); } return new TweetsConnection(first, offset, sort); } - me() { + + /** + * The currently authenticated user, if any. + * @gqlField + */ + me(): UserResolver | null { return new UserResolver(); } - async upload_statuses_by_md5({ md5s }, { ctx }) { + + /** + * Get the status of a batch of uploads by md5s + * @gqlField + */ + async upload_statuses_by_md5( + { md5s }: { md5s: string[] }, + { ctx } + ): Promise { return this._upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx); } - async upload_statuses({ ids }, { ctx }) { + /** @gqlField */ + async upload_statuses( + { ids }: { ids: string[] }, + { ctx } + ): Promise { return this._upload_statuses({ keyName: "id", keys: ids }, ctx); } @@ -152,9 +256,83 @@ class RootResolver extends MutationResolver { ); } - statistics() { + /** @gqlField */ + statistics(): DatabaseStatisticsResolver { return new DatabaseStatisticsResolver(); } } +/** @gqlType */ +type SkinUpload = { + /** @gqlField */ + id: string; + /** @gqlField */ + status: SkinUploadStatus; + /** @gqlField */ + skin: ISkin | null; + /** @gqlField */ + upload_md5: string; +}; + +/** @gqlEnum */ +type SkinsSortOption = + /* +the Museum's (https://skins.webamp.org) special sorting rules. + +Roughly speaking, it's: + +1. The four classic default skins +2. Tweeted skins first (sorted by the number of likes/retweets) +3. Approved, but not tweeted yet, skins +4. Unreviwed skins +5. Rejected skins +6. NSFW skins +*/ + "MUSEUM"; + +/** @gqlEnum */ +type SkinsFilterOption = + /* +Only the skins that have been approved for tweeting +*/ + | "APPROVED" + + /* +Only the skins that have been rejected for tweeting +*/ + | "REJECTED" + + /* +Only the skins that have been marked NSFW +*/ + | "NSFW" + + /* +Only the skins that have been tweeted +*/ + | "TWEETED"; + +/** + * The current status of a pending upload. + * **Note:** Expect more values here as we try to be more transparent about + * the status of a pending uploads. + * @gqlEnum + */ +type SkinUploadStatus = + /** The user has requested a URL, but the skin has not yet been processed. */ + | "URL_REQUESTED" + /** The user has notified us that the skin has been uploaded, but we haven't yet + processed it. */ + | "UPLOAD_REPORTED" + /** An error occured processing the skin. Usually this is a transient error, and + the skin will be retried at a later time. */ + | "ERRORED" + /** An error occured processing the skin, but it was the fault of the server. It + will be processed at a later date. */ + | "DELAYED" + /** The skin has been successfully added to the Museum. + * @deprecated + */ + | "ARCHIVED"; + export default RootResolver; diff --git a/packages/skin-database/api/graphql/resolvers/SkinResolver.ts b/packages/skin-database/api/graphql/resolvers/SkinResolver.ts index e0ed4b0f..60f82c7d 100644 --- a/packages/skin-database/api/graphql/resolvers/SkinResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/SkinResolver.ts @@ -1,12 +1,21 @@ import SkinModel from "../../../data/SkinModel"; +import UserContext from "../../../data/UserContext"; import ClassicSkinResolver from "./ClassicSkinResolver"; +import { ISkin } from "./CommonSkinResolver"; import ModernSkinResolver from "./ModernSkinResolver"; export default class SkinResolver { constructor() { throw new Error("This is a stub."); } - static fromModel(model: SkinModel) { + static async fromMd5(ctx: UserContext, md5: string): Promise { + const skin = await SkinModel.fromMd5(ctx, md5); + if (skin == null) { + return null; + } + return this.fromModel(skin); + } + static fromModel(model: SkinModel): ISkin { if (model.getSkinType() === "MODERN") { return new ModernSkinResolver(model); } else { diff --git a/packages/skin-database/api/graphql/resolvers/TweetResolver.ts b/packages/skin-database/api/graphql/resolvers/TweetResolver.ts index cfbfdbbf..4b32d93c 100644 --- a/packages/skin-database/api/graphql/resolvers/TweetResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/TweetResolver.ts @@ -1,22 +1,29 @@ +import { Int } from "grats"; import TweetModel from "../../../data/TweetModel"; +import { ISkin } from "./CommonSkinResolver"; import SkinResolver from "./SkinResolver"; +/** @gqlType Tweet */ export default class TweetResolver { _model: TweetModel; constructor(model: TweetModel) { this._model = model; } - url() { + /** @gqlField */ + url(): string | null { return this._model.getUrl(); } - likes() { + /** @gqlField */ + likes(): Int { return this._model.getLikes(); } - retweets() { + /** @gqlField */ + retweets(): Int { return this._model.getRetweets(); } - async skin() { + /** @gqlField */ + async skin(): Promise { const skin = await this._model.getSkin(); if (skin == null) { return null; diff --git a/packages/skin-database/api/graphql/resolvers/UserResolver.ts b/packages/skin-database/api/graphql/resolvers/UserResolver.ts index 4c652a6c..b8eb894e 100644 --- a/packages/skin-database/api/graphql/resolvers/UserResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/UserResolver.ts @@ -1,5 +1,7 @@ +/** @gqlType User */ export default class UserResolver { - username(_args, { ctx }) { + /** @gqlField */ + username(_args: never, { ctx }): string { return ctx.username; } } diff --git a/packages/skin-database/api/graphql/schema.graphql b/packages/skin-database/api/graphql/schema.graphql index 446b5ad6..8a987daa 100644 --- a/packages/skin-database/api/graphql/schema.graphql +++ b/packages/skin-database/api/graphql/schema.graphql @@ -599,28 +599,28 @@ type Mutation { **Note:** Requires being logged in """ - reject_skin(md5: String): Boolean + reject_skin(md5: String!): Boolean """ Approve skin for tweeting **Note:** Requires being logged in """ - approve_skin(md5: String): Boolean + approve_skin(md5: String!): Boolean """ Mark a skin as NSFW **Note:** Requires being logged in """ - mark_skin_nsfw(md5: String): Boolean + mark_skin_nsfw(md5: String!): Boolean """ Request that an admin check if this skin is NSFW. Unlike other review mutaiton endpoints, this one does not require being logged in. """ - request_nsfw_review_for_skin(md5: String): Boolean + request_nsfw_review_for_skin(md5: String!): Boolean } """ @@ -695,7 +695,7 @@ type Query { https://graphql.org/learn/global-object-identification/ """ - node(id: ID): Node + node(id: ID!): Node """ The currently authenticated user, if any. diff --git a/packages/skin-database/package.json b/packages/skin-database/package.json index 36a1cf3a..7dcde421 100644 --- a/packages/skin-database/package.json +++ b/packages/skin-database/package.json @@ -28,6 +28,7 @@ "express-graphql": "^0.12.0", "express-sitemap-xml": "^2.0.0", "graphql": "14.7.0", + "grats": "^0.0.0-main-44ac2e23", "imagemin": "^7.0.0", "imagemin-optipng": "^7.0.0", "knex": "^0.21.1", @@ -54,7 +55,9 @@ "bot": "ts-node --transpile-only ./discord-bot/index.js", "cli": "ts-node --transpile-only ./cli.ts", "sync": "ts-node --transpile-only ./tasks/syncWithArchive.ts", - "migrate": "knex migrate:latest" + "migrate": "knex migrate:latest", + "grats": "grats --output=./api/graphql/grats-schema.graphql", + "grats-progress": "yarn grats && graphql-schema-diff --fail-on-all-changes --sort-schema api/graphql/schema.graphql api/graphql/grats-schema.graphql" }, "prettier": {}, "devDependencies": { diff --git a/packages/skin-database/tsconfig.json b/packages/skin-database/tsconfig.json index a1c37d49..155e79e1 100644 --- a/packages/skin-database/tsconfig.json +++ b/packages/skin-database/tsconfig.json @@ -1,4 +1,7 @@ { + "grats": { + "nullableByDefault": true + }, "compilerOptions": { "module": "commonjs", "esModuleInterop": true, diff --git a/packages/skin-database/types.ts b/packages/skin-database/types.ts index e1ab4c5b..e801c0ac 100644 --- a/packages/skin-database/types.ts +++ b/packages/skin-database/types.ts @@ -5,6 +5,9 @@ export type TweetStatus = | "UNREVIEWED" | "NSFW"; +/** @gqlEnum */ +export type Rating = "APPROVED" | "REJECTED" | "NSFW"; + export type SkinType = "MODERN" | "CLASSIC"; export type SkinRow = { @@ -32,7 +35,7 @@ export type InstagramPostRow = { export type ReviewRow = { skin_md5: string; - review: "APPROVED" | "REJECTED" | "NSFW"; + review: Rating; reviewer: string; }; diff --git a/yarn.lock b/yarn.lock index 38093284..f80bb401 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2912,6 +2912,14 @@ dependencies: tslib "^2.4.0" +"@graphql-tools/utils@^9.2.1": + version "9.2.1" + resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-9.2.1.tgz#1b3df0ef166cfa3eae706e3518b17d5922721c57" + integrity sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A== + dependencies: + "@graphql-typed-document-node/core" "^3.1.1" + tslib "^2.4.0" + "@graphql-tools/wrap@9.2.1": version "9.2.1" resolved "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-9.2.1.tgz" @@ -2923,6 +2931,11 @@ tslib "^2.4.0" value-or-promise "1.0.11" +"@graphql-typed-document-node/core@^3.1.1": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" + integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== + "@hapi/hoek@^9.0.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" @@ -10084,6 +10097,11 @@ comma-separated-tokens@^1.0.0: resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz" integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== +commander@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.0.tgz#71797971162cd3cf65f0b9d24eb28f8d303acdf1" + integrity sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA== + commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@^2.3.0: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz" @@ -14760,6 +14778,21 @@ graphql@14.7.0: dependencies: iterall "^1.2.2" +graphql@^16.6.0: + version "16.6.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.6.0.tgz#c2dcffa4649db149f6282af726c8c83f1c7c5fdb" + integrity sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw== + +grats@^0.0.0-main-44ac2e23: + version "0.0.0-main-44ac2e23" + resolved "https://registry.yarnpkg.com/grats/-/grats-0.0.0-main-44ac2e23.tgz#b788e44c3d3eaac28588741a7c34ff4c69832183" + integrity sha512-IFVtiWuOtcjw24+TsvuZvYZci4e8jPvs7WiPrEQtsvFkZ4HEJqn+PUsMPq+R5D7Ox4NQSSTZP/r7iFUwsi19TQ== + dependencies: + "@graphql-tools/utils" "^9.2.1" + commander "^10.0.0" + graphql "^16.6.0" + typescript "^4.9.5" + gulp-eslint@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/gulp-eslint/-/gulp-eslint-3.0.1.tgz"