Disaggregate root query

This commit is contained in:
Jordan Eldredge 2023-10-27 22:36:24 -07:00
parent c34bffc44a
commit 4dbc326b03
14 changed files with 402 additions and 373 deletions

View file

@ -2,6 +2,8 @@ import { Int } from "grats";
import SkinModel from "../../data/SkinModel";
import { knex } from "../../db";
import ModernSkinResolver from "./resolvers/ModernSkinResolver";
import { Root } from "aws-sdk/clients/organizations";
import RootResolver from "./resolvers/RootResolver";
/**
* A collection of "modern" Winamp skins
@ -39,3 +41,22 @@ export default class ModernSkinsConnection {
});
}
}
/**
* All modern skins in the database
* @gqlField */
export async function modern_skins(
_: RootResolver,
{
first = 10,
offset = 0,
}: {
first?: Int;
offset?: Int;
}
): Promise<ModernSkinsConnection> {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}
return new ModernSkinsConnection(first, offset);
}

View file

@ -5,6 +5,7 @@ import SkinResolver from "./resolvers/SkinResolver";
import LRU from "lru-cache";
import { Int } from "grats";
import { ISkin } from "./resolvers/CommonSkinResolver";
import RootResolver from "./resolvers/RootResolver";
const options = {
max: 100,
@ -121,3 +122,66 @@ export default class SkinsConnection {
});
}
}
/**
* All classic skins in the database
*
* **Note:** We don't currently support combining sorting and filtering.
* @gqlField */
export function skins(
_: RootResolver,
{
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);
}
/** @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";

View file

@ -2,6 +2,7 @@ import { Int } from "grats";
import TweetModel from "../../data/TweetModel";
import { knex } from "../../db";
import TweetResolver from "./resolvers/TweetResolver";
import RootResolver from "./resolvers/RootResolver";
/** @gqlEnum */
export type TweetsSortOption = "LIKES" | "RETWEETS";
@ -54,3 +55,25 @@ export default class TweetsConnection {
});
}
}
/**
* Tweets tweeted by @winampskins
* @gqlField
*/
export async function tweets(
_: RootResolver,
{
first = 10,
offset = 0,
sort,
}: {
first?: Int;
offset?: Int;
sort?: TweetsSortOption;
}
): Promise<TweetsConnection> {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}
return new TweetsConnection(first, offset, sort);
}

View file

@ -3,6 +3,7 @@ import ArchiveFileModel from "../../../data/ArchiveFileModel";
import SkinModel from "../../../data/SkinModel";
import { ISkin } from "./CommonSkinResolver";
import SkinResolver from "./SkinResolver";
import RootResolver from "./RootResolver";
/**
* A file found within a Winamp Skin's .wsz archive
@ -79,3 +80,21 @@ export default class ArchiveFileResolver {
return this._model.getFileDate().toISOString();
}
}
/**
* Fetch archive file by it's MD5 hash
*
* Get information about a file found within a skin's wsz/wal/zip archive.
* @gqlField
*/
export async function fetch_archive_file_by_md5(
_: RootResolver,
{ md5 }: { md5: string },
{ ctx }
): Promise<ArchiveFileResolver | null> {
const archiveFile = await ArchiveFileModel.fromFileMd5(ctx, md5);
if (archiveFile == null) {
return null;
}
return new ArchiveFileResolver(archiveFile);
}

View file

@ -1,5 +1,6 @@
import { Int } from "grats";
import * as Skins from "../../../data/skins";
import RootResolver from "./RootResolver";
/**
* Statistics about the contents of the Museum's database.
@ -88,3 +89,10 @@ export default class DatabaseStatisticsResolver {
return Skins.getWebUploadsCount();
}
}
/**
* A namespace for statistics about the database
* @gqlField */
export function statistics(_: RootResolver): DatabaseStatisticsResolver {
return new DatabaseStatisticsResolver();
}

View file

@ -1,5 +1,6 @@
import IaItemModel from "../../../data/IaItemModel";
import { ISkin } from "./CommonSkinResolver";
import RootResolver from "./RootResolver";
import SkinResolver from "./SkinResolver";
/** @gqlType InternetArchiveItem */
@ -59,3 +60,21 @@ export default class InternetArchiveItemResolver {
return SkinResolver.fromModel(skin);
}
}
/**
* Get an archive.org item by its identifier. You can find this in the URL:
*
* https://archive.org/details/<identifier>/
* @gqlField
*/
export async function fetch_internet_archive_item_by_identifier(
_: RootResolver,
{ identifier }: { identifier: string },
{ ctx }
): Promise<InternetArchiveItemResolver | null> {
const iaItem = await IaItemModel.fromIdentifier(ctx, identifier);
if (iaItem == null) {
return null;
}
return new InternetArchiveItemResolver(iaItem);
}

View file

@ -1,3 +1,4 @@
import SkinResolver from "./SkinResolver";
import SkinModel from "../../../data/SkinModel";
import CommonSkinResolver, { ISkin } from "./CommonSkinResolver";
import { NodeResolver, toId } from "./NodeResolver";
@ -8,6 +9,7 @@ import InternetArchiveItemResolver from "./InternetArchiveItemResolver";
import ArchiveFileResolver from "./ArchiveFileResolver";
import TweetResolver from "./TweetResolver";
import { XMLParser } from "fast-xml-parser";
import RootResolver from "./RootResolver";
/**
* A "modern" Winamp skin. These skins use the `.wal` file extension and are free-form.
@ -169,3 +171,23 @@ export default class ModernSkinResolver
return null;
}
}
/**
* Get a skin by its MD5 hash
* @gqlField
*/
export async function fetch_skin_by_md5(
_: RootResolver,
{ md5 }: { md5: string },
{ ctx }
): Promise<ISkin | null> {
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
return null;
}
if (skin.getSkinType() === "MODERN") {
return new ModernSkinResolver(skin);
} else {
return SkinResolver.fromModel(skin);
}
}

View file

@ -1,5 +1,9 @@
import { ID } from "grats";
import SkinModel from "../../../data/SkinModel";
import SkinResolver from "../resolvers/SkinResolver";
import RootResolver from "./RootResolver";
/**
* A globally unique object. The `id` here is intended only for use within
* GraphQL.
@ -16,6 +20,32 @@ export interface NodeResolver {
__typename: string;
}
/**
* Get a globally unique object by its ID.
*
* https://graphql.org/learn/global-object-identification/
* @gqlField
*/
export async function node(
_: RootResolver,
{ id }: { id: ID },
{ ctx }
): Promise<NodeResolver | null> {
const { graphqlType, id: localId } = fromId(id);
// TODO Use typeResolver
switch (graphqlType) {
case "ClassicSkin":
case "ModernSkin": {
const skin = await SkinModel.fromMd5(ctx, localId);
if (skin == null) {
return null;
}
return SkinResolver.fromModel(skin);
}
}
return null;
}
export function toId(graphqlType: string, id: string) {
return Buffer.from(`${graphqlType}__${id}`).toString("base64");
}

View file

@ -1,365 +1,7 @@
import IaItemModel from "../../../data/IaItemModel";
import SkinModel from "../../../data/SkinModel";
import TweetModel from "../../../data/TweetModel";
import SkinResolver from "../resolvers/SkinResolver";
import TweetResolver from "../resolvers/TweetResolver";
import UserResolver from "../resolvers/UserResolver";
import SkinsConnection from "../SkinsConnection";
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";
import { knex } from "../../../db";
import ArchiveFileModel from "../../../data/ArchiveFileModel";
import ArchiveFileResolver from "./ArchiveFileResolver";
import DatabaseStatisticsResolver from "./DatabaseStatisticsResolver";
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 {
/**
* Get a globally unique object by its ID.
*
* https://graphql.org/learn/global-object-identification/
* @gqlField
*/
async node({ id }: { id: ID }, { ctx }): Promise<NodeResolver | null> {
const { graphqlType, id: localId } = fromId(id);
// TODO Use typeResolver
switch (graphqlType) {
case "ClassicSkin":
case "ModernSkin": {
const skin = await SkinModel.fromMd5(ctx, localId);
if (skin == null) {
return null;
}
return SkinResolver.fromModel(skin);
}
}
return null;
}
/**
* Get a skin by its MD5 hash
* @gqlField
*/
async fetch_skin_by_md5(
{ md5 }: { md5: string },
{ ctx }
): Promise<ISkin | null> {
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
return null;
}
if (skin.getSkinType() === "MODERN") {
return new ModernSkinResolver(skin);
} else {
return SkinResolver.fromModel(skin);
}
}
/**
* Get a tweet by its URL
* @gqlField
*/
async fetch_tweet_by_url(
{ url }: { url: string },
{ ctx }
): Promise<TweetResolver | null> {
const tweet = await TweetModel.fromAnything(ctx, url);
if (tweet == null) {
return null;
}
return new TweetResolver(tweet);
}
/**
* Get an archive.org item by its identifier. You can find this in the URL:
*
* https://archive.org/details/<identifier>/
* @gqlField
*/
async fetch_internet_archive_item_by_identifier(
{ identifier }: { identifier: string },
{ ctx }
): Promise<InternetArchiveItemResolver | null> {
const iaItem = await IaItemModel.fromIdentifier(ctx, identifier);
if (iaItem == null) {
return null;
}
return new InternetArchiveItemResolver(iaItem);
}
/**
* 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<ArchiveFileResolver | null> {
const archiveFile = await ArchiveFileModel.fromFileMd5(ctx, md5);
if (archiveFile == null) {
return null;
}
return new ArchiveFileResolver(archiveFile);
}
/**
* 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<Array<ISkin | null>> {
if (first > 1000) {
throw new Error("Can only query 1000 records via search.");
}
const results: { hits: { md5: string }[] } = await index.search(query, {
attributesToRetrieve: ["md5"],
length: first,
offset,
});
return Promise.all(
results.hits.map(async (hit) => {
const model = await SkinModel.fromMd5Assert(ctx, hit.md5);
return SkinResolver.fromModel(model);
})
);
}
/**
* All classic skins in the database
*
* **Note:** We don't currently support combining sorting and filtering.
* @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);
}
/**
* All modern skins in the database
* @gqlField */
async modern_skins({
first = 10,
offset = 0,
}: {
first?: Int;
offset?: Int;
}): Promise<ModernSkinsConnection> {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}
return new ModernSkinsConnection(first, offset);
}
/**
* A random skin that needs to be reviewed
* @gqlField */
async skin_to_review(_args: never, { ctx }): Promise<ISkin | null> {
if (!ctx.authed()) {
return null;
}
const { md5 } = await Skins.getSkinToReview();
const model = await SkinModel.fromMd5Assert(ctx, md5);
return SkinResolver.fromModel(model);
}
/**
* Tweets tweeted by @winampskins
* @gqlField
*/
async tweets({
first = 10,
offset = 0,
sort,
}: {
first?: Int;
offset?: Int;
sort?: TweetsSortOption;
}): Promise<TweetsConnection> {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}
return new TweetsConnection(first, offset, sort);
}
/**
* The currently authenticated user, if any.
* @gqlField
*/
me(): UserResolver | null {
return new UserResolver();
}
/**
* Get the status of a batch of uploads by md5s
* @gqlField
* @deprecated Prefer `upload_statuses` instead, were we operate on ids.
*/
async upload_statuses_by_md5(
{ md5s }: { md5s: string[] },
{ ctx }
): Promise<Array<SkinUpload | null>> {
return this._upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx);
}
/**
* Get the status of a batch of uploads by ids
* @gqlField */
async upload_statuses(
{ ids }: { ids: string[] },
{ ctx }
): Promise<Array<SkinUpload | null>> {
return this._upload_statuses({ keyName: "id", keys: ids }, ctx);
}
// Shared implementation for upload_statuses and upload_statuses_by_md5
async _upload_statuses({ keyName, keys }, ctx) {
const skins = await knex("skin_uploads")
.whereIn(keyName, keys)
.orderBy("id", "desc")
.select("id", "skin_md5", "status");
return Promise.all(
skins.map(async ({ id, skin_md5, status }) => {
// TODO: Could we avoid fetching the skin if it's not read?
const skinModel = await SkinModel.fromMd5(ctx, skin_md5);
const skin =
skinModel == null ? null : SkinResolver.fromModel(skinModel);
// Most of the time when a skin fails to process, it's due to some infa
// issue on our side, and we can recover. For now, we'll always tell the user
// That processing is just delayed.
status = status === "ERRORED" ? "DELAYED" : status;
return { id, skin, status, upload_md5: skin_md5 };
})
);
}
/**
* A namespace for statistics about the database
* @gqlField */
statistics(): DatabaseStatisticsResolver {
return new DatabaseStatisticsResolver();
}
}
/**
* Information about an attempt to upload a skin to the Museum.
* @gqlType
*/
type SkinUpload = {
/** @gqlField */
id: string;
/** @gqlField */
status: SkinUploadStatus;
/**
* Skin that was uploaded. **Note:** This is null if the skin has not yet been
* fully processed. (status == ARCHIVED)
* @gqlField
*/
skin: ISkin | null;
/**
* Md5 hash given when requesting the upload URL.
* @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";
class RootResolver extends MutationResolver {}
export default RootResolver;

View file

@ -1,8 +1,17 @@
import * as Skins from "../../../data/skins";
import SkinModel from "../../../data/SkinModel";
import UserContext from "../../../data/UserContext";
import ClassicSkinResolver from "./ClassicSkinResolver";
import { ISkin } from "./CommonSkinResolver";
import ModernSkinResolver from "./ModernSkinResolver";
import RootResolver from "./RootResolver";
import { Int } from "grats";
import algoliasearch from "algoliasearch";
// 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");
export default class SkinResolver {
constructor() {
@ -23,3 +32,52 @@ export default class SkinResolver {
}
}
}
/**
* Search the database using the Algolia search index used by the Museum.
*
* Useful for locating a particular skin.
* @gqlField
*/
export async function search_skins(
_: RootResolver,
{
query,
first = 10,
offset = 0,
}: { query: string; first?: Int; offset?: Int },
{ ctx }
): Promise<Array<ISkin | null>> {
if (first > 1000) {
throw new Error("Can only query 1000 records via search.");
}
const results: { hits: { md5: string }[] } = await index.search(query, {
attributesToRetrieve: ["md5"],
length: first,
offset,
});
return Promise.all(
results.hits.map(async (hit) => {
const model = await SkinModel.fromMd5Assert(ctx, hit.md5);
return SkinResolver.fromModel(model);
})
);
}
/**
* A random skin that needs to be reviewed
* @gqlField */
export async function skin_to_review(
_: RootResolver,
_args: never,
{ ctx }
): Promise<ISkin | null> {
if (!ctx.authed()) {
return null;
}
const { md5 } = await Skins.getSkinToReview();
const model = await SkinModel.fromMd5Assert(ctx, md5);
return SkinResolver.fromModel(model);
}

View file

@ -0,0 +1,96 @@
import SkinModel from "../../../data/SkinModel";
import SkinResolver from "../resolvers/SkinResolver";
import { knex } from "../../../db";
import { ISkin } from "./CommonSkinResolver";
import RootResolver from "./RootResolver";
/**
* Information about an attempt to upload a skin to the Museum.
* @gqlType
*/
type SkinUpload = {
/** @gqlField */
id: string;
/** @gqlField */
status: SkinUploadStatus;
/**
* Skin that was uploaded. **Note:** This is null if the skin has not yet been
* fully processed. (status == ARCHIVED)
* @gqlField
*/
skin: ISkin | null;
/**
* Md5 hash given when requesting the upload URL.
* @gqlField
*/
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.
* @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";
/**
* Get the status of a batch of uploads by md5s
* @gqlField
* @deprecated Prefer `upload_statuses` instead, were we operate on ids.
*/
export async function upload_statuses_by_md5(
_: RootResolver,
{ md5s }: { md5s: string[] },
{ ctx }
): Promise<Array<SkinUpload | null>> {
return _upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx);
}
/**
* Get the status of a batch of uploads by ids
* @gqlField */
export async function upload_statuses(
_: RootResolver,
{ ids }: { ids: string[] },
{ ctx }
): Promise<Array<SkinUpload | null>> {
return _upload_statuses({ keyName: "id", keys: ids }, ctx);
}
// Shared implementation for upload_statuses and upload_statuses_by_md5
async function _upload_statuses({ keyName, keys }, ctx) {
const skins = await knex("skin_uploads")
.whereIn(keyName, keys)
.orderBy("id", "desc")
.select("id", "skin_md5", "status");
return Promise.all(
skins.map(async ({ id, skin_md5, status }) => {
// TODO: Could we avoid fetching the skin if it's not read?
const skinModel = await SkinModel.fromMd5(ctx, skin_md5);
const skin = skinModel == null ? null : SkinResolver.fromModel(skinModel);
// Most of the time when a skin fails to process, it's due to some infa
// issue on our side, and we can recover. For now, we'll always tell the user
// That processing is just delayed.
status = status === "ERRORED" ? "DELAYED" : status;
return { id, skin, status, upload_md5: skin_md5 };
})
);
}

View file

@ -2,6 +2,7 @@ import { Int } from "grats";
import TweetModel from "../../../data/TweetModel";
import { ISkin } from "./CommonSkinResolver";
import SkinResolver from "./SkinResolver";
import RootResolver from "./RootResolver";
/**
* A tweet made by @winampskins mentioning a Winamp skin
@ -48,3 +49,19 @@ export default class TweetResolver {
return SkinResolver.fromModel(skin);
}
}
/**
* Get a tweet by its URL
* @gqlField
*/
export async function fetch_tweet_by_url(
_: RootResolver,
{ url }: { url: string },
{ ctx }
): Promise<TweetResolver | null> {
const tweet = await TweetModel.fromAnything(ctx, url);
if (tweet == null) {
return null;
}
return new TweetResolver(tweet);
}

View file

@ -1,3 +1,5 @@
import RootResolver from "./RootResolver";
/** @gqlType User */
export default class UserResolver {
/** @gqlField */
@ -5,3 +7,11 @@ export default class UserResolver {
return ctx.username;
}
}
/**
* The currently authenticated user, if any.
* @gqlField
*/
export function me(_: RootResolver): UserResolver | null {
return new UserResolver();
}

View file

@ -264,49 +264,49 @@ type Query {
Get information about a file found within a skin's wsz/wal/zip archive.
"""
fetch_archive_file_by_md5(md5: String!): ArchiveFile
fetch_archive_file_by_md5(md5: String!): ArchiveFile @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/ArchiveFileResolver.js", functionName: "fetch_archive_file_by_md5")
"""
Get an archive.org item by its identifier. You can find this in the URL:
https://archive.org/details/<identifier>/
"""
fetch_internet_archive_item_by_identifier(identifier: String!): InternetArchiveItem
fetch_internet_archive_item_by_identifier(identifier: String!): InternetArchiveItem @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/InternetArchiveItemResolver.js", functionName: "fetch_internet_archive_item_by_identifier")
"""Get a skin by its MD5 hash"""
fetch_skin_by_md5(md5: String!): Skin
fetch_skin_by_md5(md5: String!): Skin @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/ModernSkinResolver.js", functionName: "fetch_skin_by_md5")
"""Get a tweet by its URL"""
fetch_tweet_by_url(url: String!): Tweet
fetch_tweet_by_url(url: String!): Tweet @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/TweetResolver.js", functionName: "fetch_tweet_by_url")
"""The currently authenticated user, if any."""
me: User
me: User @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/UserResolver.js", functionName: "me")
"""All modern skins in the database"""
modern_skins(first: Int = 10, offset: Int = 0): ModernSkinsConnection
modern_skins(first: Int = 10, offset: Int = 0): ModernSkinsConnection @exported(filename: "../../../../packages/skin-database/dist/api/graphql/ModernSkinsConnection.js", functionName: "modern_skins")
"""
Get a globally unique object by its ID.
https://graphql.org/learn/global-object-identification/
"""
node(id: ID!): Node
node(id: ID!): Node @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/NodeResolver.js", functionName: "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]
search_skins(first: Int = 10, offset: Int = 0, query: String!): [Skin] @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/SkinResolver.js", functionName: "search_skins")
"""A random skin that needs to be reviewed"""
skin_to_review: Skin
skin_to_review: Skin @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/SkinResolver.js", functionName: "skin_to_review")
"""
All classic skins in the database
**Note:** We don't currently support combining sorting and filtering.
"""
skins(filter: SkinsFilterOption, first: Int = 10, offset: Int = 0, sort: SkinsSortOption): SkinsConnection
skins(filter: SkinsFilterOption, first: Int = 10, offset: Int = 0, sort: SkinsSortOption): SkinsConnection @exported(filename: "../../../../packages/skin-database/dist/api/graphql/SkinsConnection.js", functionName: "skins")
"""A namespace for statistics about the database"""
statistics: DatabaseStatistics
statistics: DatabaseStatistics @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/DatabaseStatisticsResolver.js", functionName: "statistics")
"""Tweets tweeted by @winampskins"""
tweets(first: Int = 10, offset: Int = 0, sort: TweetsSortOption): TweetsConnection
tweets(first: Int = 10, offset: Int = 0, sort: TweetsSortOption): TweetsConnection @exported(filename: "../../../../packages/skin-database/dist/api/graphql/TweetsConnection.js", functionName: "tweets")
"""Get the status of a batch of uploads by ids"""
upload_statuses(ids: [String!]!): [SkinUpload]
upload_statuses(ids: [String!]!): [SkinUpload] @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/SkinUploadResolver.js", functionName: "upload_statuses")
"""Get the status of a batch of uploads by md5s"""
upload_statuses_by_md5(md5s: [String!]!): [SkinUpload] @deprecated(reason: "Prefer `upload_statuses` instead, were we operate on ids.")
upload_statuses_by_md5(md5s: [String!]!): [SkinUpload] @deprecated(reason: "Prefer `upload_statuses` instead, were we operate on ids.") @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/SkinUploadResolver.js", functionName: "upload_statuses_by_md5")
}
"""The judgement made about a skin by a moderator"""