fix: query type inference

This commit is contained in:
DecDuck
2025-08-11 15:29:12 +10:00
parent 7c234067a5
commit d0475d3ebd
18 changed files with 274 additions and 250 deletions

View File

@ -26,8 +26,8 @@
<div class="flex-auto">
<h3 class="text-sm/6 font-semibold text-zinc-100">
<button :class="actionsComplete[actionIdx]
? 'line-through text-zinc-300'
: ''
? 'line-through text-zinc-300'
: ''
" @click="() => (currentAction = actionIdx)">
<span class="absolute inset-0" aria-hidden="true" />
{{ action.name }}

View File

@ -6,30 +6,31 @@ const SearchGame = type({
q: "string",
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type APIQuery = typeof SearchGame.infer;
/**
* Search metadata providers for a query. Results can be used to import a game with metadata.
*/
export default defineEventHandler<{ query: typeof SearchGame.infer }>(
async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]);
if (!allowed) throw createError({ statusCode: 403 });
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const query = getQuery(h3);
const search = SearchGame(query);
if (search instanceof ArkErrors)
throw createError({
statusCode: 400,
statusMessage: "Invalid search: " + search.summary,
});
const query = getQuery(h3);
const search = SearchGame(query);
if (search instanceof ArkErrors)
throw createError({
statusCode: 400,
statusMessage: "Invalid search: " + search.summary,
});
const results = await metadataHandler.search(search.q);
const results = await metadataHandler.search(search.q);
if (results.length == 0)
throw createError({
statusCode: 404,
statusMessage: "No metadata provider returned search results.",
});
if (results.length == 0)
throw createError({
statusCode: 404,
statusMessage: "No metadata provider returned search results.",
});
return results;
},
);
return results;
});

View File

@ -7,10 +7,13 @@ const Query = type({
id: "string",
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type APIQuery = typeof Query.inferIn;
/**
* Fetch all versions available for import for a game (`id` in query params).
*/
export default defineEventHandler<{ query: typeof Query.infer }>(async (h3) => {
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:read"]);
if (!allowed) throw createError({ statusCode: 403 });

View File

@ -7,10 +7,13 @@ const Query = type({
version: "string",
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type APIQuery = typeof Query.inferIn;
/**
* Fetch recommendations for version import.
*/
export default defineEventHandler<{ query: typeof Query.infer }>(async (h3) => {
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:read"]);
if (!allowed) throw createError({ statusCode: 403 });

View File

@ -7,10 +7,13 @@ const Query = type({
id: "string",
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type APIQuery = typeof Query.inferIn;
/**
* Fetch invitation details for pre-filling
*/
export default defineEventHandler<{ query: typeof Query.infer }>(async (h3) => {
export default defineEventHandler(async (h3) => {
const t = await useTranslation(h3);
if (!authManager.getAuthProviders().Simple)

View File

@ -6,10 +6,13 @@ const Query = type({
code: "string.upper",
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type APIQuery = typeof Query.inferIn;
/**
* Fetch client ID by authorize code
*/
export default defineEventHandler<{ query: typeof Query.infer }>(async (h3) => {
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, []);
if (!userId) throw createError({ statusCode: 403 });

View File

@ -6,10 +6,13 @@ const Query = type({
id: "string",
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type APIQuery = typeof Query.inferIn;
/**
* Fetch details about an authorization request, and claim it for the current user
*/
export default defineEventHandler<{ query: typeof Query.infer }>(async (h3) => {
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, []);
if (!userId) throw createError({ statusCode: 403 });

View File

@ -18,86 +18,89 @@ const Query = type({
chunk: "string.numeric.parse",
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type APIQuery = typeof Query.inferIn;
/**
* v1 download API
* @deprecated
* @response Raw binary data (`application/octet-stream`)
*/
export default defineClientEventHandler<unknown, { query: typeof Query.infer }>(
async (h3) => {
const query = Query(getQuery(h3));
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: query.summary });
const gameId = query.id;
const versionName = query.version;
const filename = query.name;
const chunkIndex = query.chunk;
export default defineClientEventHandler(async (h3) => {
const query = Query(getQuery(h3));
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: query.summary });
const gameId = query.id;
const versionName = query.version;
const filename = query.name;
const chunkIndex = query.chunk;
if (!gameId || !versionName || !filename || Number.isNaN(chunkIndex))
if (!gameId || !versionName || !filename || Number.isNaN(chunkIndex))
throw createError({
statusCode: 400,
statusMessage: "Invalid chunk arguments",
});
let game = await gameLookupCache.getItem(gameId);
if (!game) {
game = await prisma.game.findUnique({
where: {
id: gameId,
},
select: {
libraryId: true,
libraryPath: true,
},
});
if (!game || !game.libraryId)
throw createError({
statusCode: 400,
statusMessage: "Invalid chunk arguments",
statusMessage: "Invalid game ID",
});
let game = await gameLookupCache.getItem(gameId);
if (!game) {
game = await prisma.game.findUnique({
where: {
id: gameId,
},
select: {
libraryId: true,
libraryPath: true,
},
});
if (!game || !game.libraryId)
throw createError({
statusCode: 400,
statusMessage: "Invalid game ID",
});
await gameLookupCache.setItem(gameId, game);
}
await gameLookupCache.setItem(gameId, game);
}
if (!game.libraryId)
throw createError({
statusCode: 500,
statusMessage: "Somehow, we got here.",
});
if (!game.libraryId)
throw createError({
statusCode: 500,
statusMessage: "Somehow, we got here.",
});
const peek = await libraryManager.peekFile(
game.libraryId,
game.libraryPath,
versionName,
filename,
);
if (!peek)
throw createError({ status: 400, statusMessage: "Failed to peek file" });
const peek = await libraryManager.peekFile(
game.libraryId,
game.libraryPath,
versionName,
filename,
);
if (!peek)
throw createError({ status: 400, statusMessage: "Failed to peek file" });
const start = chunkIndex * chunkSize;
const end = Math.min((chunkIndex + 1) * chunkSize, peek.size);
const currentChunkSize = end - start;
setHeader(h3, "Content-Length", currentChunkSize);
const start = chunkIndex * chunkSize;
const end = Math.min((chunkIndex + 1) * chunkSize, peek.size);
const currentChunkSize = end - start;
setHeader(h3, "Content-Length", currentChunkSize);
if (start >= end)
throw createError({
statusCode: 400,
statusMessage: "Invalid chunk index",
});
if (start >= end)
throw createError({
statusCode: 400,
statusMessage: "Invalid chunk index",
});
const gameReadStream = await libraryManager.readFile(
game.libraryId,
game.libraryPath,
versionName,
filename,
{ start, end },
);
if (!gameReadStream)
throw createError({
statusCode: 400,
statusMessage: "Failed to create stream",
});
const gameReadStream = await libraryManager.readFile(
game.libraryId,
game.libraryPath,
versionName,
filename,
{ start, end },
);
if (!gameReadStream)
throw createError({
statusCode: 400,
statusMessage: "Failed to create stream",
});
await sendStream(h3, gameReadStream);
return sendStream(h3, gameReadStream);
},
);
return;
});

View File

@ -12,34 +12,35 @@ const NewsFetch = type({
"search?": "string",
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type APIQuery = typeof NewsFetch.infer;
/**
* Fetch instance news articles
*/
export default defineClientEventHandler<ReturnType<typeof newsManager.fetch>, { query: typeof NewsFetch.infer }>(
async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["news:read"]);
if (!userId)
throw createError({
statusCode: 403,
statusMessage: "Requires authentication",
});
export default defineClientEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["news:read"]);
if (!userId)
throw createError({
statusCode: 403,
statusMessage: "Requires authentication",
});
const query = NewsFetch(getQuery(h3));
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: query.summary });
const query = NewsFetch(getQuery(h3));
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: query.summary });
const orderBy = query.order;
const tags = query.tags;
const orderBy = query.order;
const tags = query.tags;
const options = {
take: Math.min(query.limit ?? 10, 10),
skip: query.skip ?? 0,
orderBy: orderBy,
...(tags && { tags: tags.map((e) => e.toString()) }),
search: query.search,
};
const options = {
take: Math.min(query.limit ?? 10, 10),
skip: query.skip ?? 0,
orderBy: orderBy,
...(tags && { tags: tags.map((e) => e.toString()) }),
search: query.search,
};
const news = await newsManager.fetch(options);
return news;
},
);
const news = await newsManager.fetch(options);
return news;
});

View File

@ -11,22 +11,24 @@ const AddEntry = type({
* Add game to collection
* @param id Collection ID
*/
export default defineEventHandler<{ body: typeof AddEntry.infer }>(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["collections:add"]);
if (!userId)
throw createError({
statusCode: 403,
});
export default defineEventHandler<{ body: typeof AddEntry.infer }>(
async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["collections:add"]);
if (!userId)
throw createError({
statusCode: 403,
});
const id = getRouterParam(h3, "id");
if (!id)
throw createError({
statusCode: 400,
statusMessage: "ID required in route params",
});
const id = getRouterParam(h3, "id");
if (!id)
throw createError({
statusCode: 400,
statusMessage: "ID required in route params",
});
const body = await readDropValidatedBody(h3, AddEntry);
const gameId = body.id;
const body = await readDropValidatedBody(h3, AddEntry);
const gameId = body.id;
return await userLibraryManager.collectionAdd(gameId, id, userId);
});
return await userLibraryManager.collectionAdd(gameId, id, userId);
},
);

View File

@ -11,34 +11,35 @@ const NewsFetch = type({
"search?": "string",
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type APIQuery = typeof NewsFetch.infer;
/**
* Fetch instance news articles
*/
export default defineEventHandler<{ query: typeof NewsFetch.infer }, ReturnType<typeof newsManager.fetch>>(
async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["news:read"]);
if (!userId)
throw createError({
statusCode: 403,
statusMessage: "Requires authentication",
});
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["news:read"]);
if (!userId)
throw createError({
statusCode: 403,
statusMessage: "Requires authentication",
});
const query = NewsFetch(getQuery(h3));
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: query.summary });
const query = NewsFetch(getQuery(h3));
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: query.summary });
const orderBy = query.order;
const tags = query.tags;
const orderBy = query.order;
const tags = query.tags;
const options = {
take: Math.min(query.limit ?? 10, 10),
skip: query.skip ?? 0,
orderBy: orderBy,
...(tags && { tags: tags.map((e) => e.toString()) }),
search: query.search,
};
const options = {
take: Math.min(query.limit ?? 10, 10),
skip: query.skip ?? 0,
orderBy: orderBy,
...(tags && { tags: tags.map((e) => e.toString()) }),
search: query.search,
};
const news = await newsManager.fetch(options);
return news;
},
);
const news = await newsManager.fetch(options);
return news;
});

View File

@ -21,107 +21,108 @@ const StoreRead = type({
sort: "'default' | 'newest' | 'recent' = 'default'",
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type APIQuery = typeof StoreRead.inferIn;
/**
* Store endpoint. Filter games with pagination. Used for all "store views".
*/
export default defineEventHandler<{ query: typeof StoreRead.infer }>(
async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 });
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 });
const query = getQuery(h3);
const options = StoreRead(query);
if (options instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: options.summary });
const query = getQuery(h3);
const options = StoreRead(query);
if (options instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: options.summary });
/**
* Generic filters
*/
const tagFilter = options.tags
? {
tags: {
some: {
id: {
in: options.tags.split(","),
},
/**
* Generic filters
*/
const tagFilter = options.tags
? {
tags: {
some: {
id: {
in: options.tags.split(","),
},
},
}
: undefined;
const platformFilter = options.platform
? {
versions: {
some: {
platform: {
in: options.platform
.split(",")
.map(parsePlatform)
.filter((e) => e !== undefined),
},
},
}
: undefined;
const platformFilter = options.platform
? {
versions: {
some: {
platform: {
in: options.platform
.split(",")
.map(parsePlatform)
.filter((e) => e !== undefined),
},
},
}
: undefined;
},
}
: undefined;
/**
* Company filtering
*/
const companyActions = options.companyActions.split(",");
const developedFilter = companyActions.includes("developed")
? {
developers: {
some: {
id: options.company!,
},
/**
* Company filtering
*/
const companyActions = options.companyActions.split(",");
const developedFilter = companyActions.includes("developed")
? {
developers: {
some: {
id: options.company!,
},
}
: undefined;
const publishedFilter = companyActions.includes("published")
? {
publishers: {
some: {
id: options.company!,
},
},
}
: undefined;
const publishedFilter = companyActions.includes("published")
? {
publishers: {
some: {
id: options.company!,
},
}
: undefined;
const companyFilter = options.company
? ({
OR: [developedFilter, publishedFilter].filter((e) => e !== undefined),
} satisfies Prisma.GameWhereInput)
: undefined;
},
}
: undefined;
const companyFilter = options.company
? ({
OR: [developedFilter, publishedFilter].filter((e) => e !== undefined),
} satisfies Prisma.GameWhereInput)
: undefined;
/**
* Query
*/
/**
* Query
*/
const finalFilter: Prisma.GameWhereInput = {
...tagFilter,
...platformFilter,
...companyFilter,
};
const finalFilter: Prisma.GameWhereInput = {
...tagFilter,
...platformFilter,
...companyFilter,
};
const sort: Prisma.GameOrderByWithRelationInput = {};
switch (options.sort) {
case "default":
case "newest":
sort.mReleased = "desc";
break;
case "recent":
sort.created = "desc";
break;
}
const sort: Prisma.GameOrderByWithRelationInput = {};
switch (options.sort) {
case "default":
case "newest":
sort.mReleased = "desc";
break;
case "recent":
sort.created = "desc";
break;
}
const [results, count] = await prisma.$transaction([
prisma.game.findMany({
skip: options.skip,
take: Math.min(options.take, 50),
where: finalFilter,
orderBy: sort,
}),
prisma.game.count({ where: finalFilter }),
]);
const [results, count] = await prisma.$transaction([
prisma.game.findMany({
skip: options.skip,
take: Math.min(options.take, 50),
where: finalFilter,
orderBy: sort,
}),
prisma.game.count({ where: finalFilter }),
]);
return { results, count };
},
);
return { results, count };
});

View File

@ -16,7 +16,7 @@ type ClientUtils<R> = {
const NONCE_LENIENCE = 30_000;
type ClientEventHandlerRequest<T, Q = {[key: string]: any }> = {
type ClientEventHandlerRequest<T, Q = { [key: string]: any }> = {
body?: T;
query?: Q;
};