chore: Migrate to namespace

This commit is contained in:
Wanjohi
2025-09-20 17:20:16 +03:00
parent eb41fdc754
commit aba0bc3be1
7 changed files with 1014 additions and 976 deletions

View File

@@ -5,62 +5,76 @@ import { Examples } from "../examples";
import { createEvent } from "../event"; import { createEvent } from "../event";
import { eq, isNull, and } from "drizzle-orm"; import { eq, isNull, and } from "drizzle-orm";
import { ImageTypeEnum } from "../images/images.sql"; import { ImageTypeEnum } from "../images/images.sql";
import { createTransaction, useTransaction } from "../drizzle/transaction"; import { Database } from "../drizzle";
import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum, Links } from "./base-game.sql"; import {
CompatibilityEnum,
baseGamesTable,
Size,
ControllerEnum,
Links,
} from "./base-game.sql";
export namespace BaseGame { export namespace BaseGame {
export const Info = z.object({ export const Info = z
.object({
id: z.string().openapi({ id: z.string().openapi({
description: Common.IdDescription, description: Common.IdDescription,
example: Examples.BaseGame.id example: Examples.BaseGame.id,
}), }),
slug: z.string().openapi({ slug: z.string().openapi({
description: "A URL-friendly unique identifier for the game, used in web addresses and API endpoints", description:
example: Examples.BaseGame.slug "A URL-friendly unique identifier for the game, used in web addresses and API endpoints",
example: Examples.BaseGame.slug,
}), }),
name: z.string().openapi({ name: z.string().openapi({
description: "The official title of the game as listed on Steam", description: "The official title of the game as listed on Steam",
example: Examples.BaseGame.name example: Examples.BaseGame.name,
}), }),
size: Size.openapi({ size: Size.openapi({
description: "Storage requirements in bytes: downloadSize represents the compressed download, and sizeOnDisk represents the installed size", description:
example: Examples.BaseGame.size "Storage requirements in bytes: downloadSize represents the compressed download, and sizeOnDisk represents the installed size",
example: Examples.BaseGame.size,
}), }),
releaseDate: z.date().openapi({ releaseDate: z.date().openapi({
description: "The initial public release date of the game on Steam", description: "The initial public release date of the game on Steam",
example: Examples.BaseGame.releaseDate example: Examples.BaseGame.releaseDate,
}), }),
description: z.string().nullable().openapi({ description: z.string().nullable().openapi({
description: "A comprehensive overview of the game, including its features, storyline, and gameplay elements", description:
example: Examples.BaseGame.description "A comprehensive overview of the game, including its features, storyline, and gameplay elements",
example: Examples.BaseGame.description,
}), }),
score: z.number().openapi({ score: z.number().openapi({
description: "The aggregate user review score on Steam, represented as a percentage of positive reviews", description:
example: Examples.BaseGame.score "The aggregate user review score on Steam, represented as a percentage of positive reviews",
example: Examples.BaseGame.score,
}), }),
links: Links links: Links.nullable().openapi({
.nullable()
.openapi({
description: "The social links of this game", description: "The social links of this game",
example: Examples.BaseGame.links example: Examples.BaseGame.links,
}), }),
primaryGenre: z.string().nullable().openapi({ primaryGenre: z.string().nullable().openapi({
description: "The main category or genre that best represents the game's content and gameplay style", description:
example: Examples.BaseGame.primaryGenre "The main category or genre that best represents the game's content and gameplay style",
example: Examples.BaseGame.primaryGenre,
}), }),
controllerSupport: z.enum(ControllerEnum.enumValues).openapi({ controllerSupport: z.enum(ControllerEnum.enumValues).openapi({
description: "Indicates the level of gamepad/controller compatibility: 'Full', 'Partial', or 'Unkown' for no support", description:
example: Examples.BaseGame.controllerSupport "Indicates the level of gamepad/controller compatibility: 'Full', 'Partial', or 'Unkown' for no support",
example: Examples.BaseGame.controllerSupport,
}), }),
compatibility: z.enum(CompatibilityEnum.enumValues).openapi({ compatibility: z.enum(CompatibilityEnum.enumValues).openapi({
description: "Steam Deck/Proton compatibility rating indicating how well the game runs on Linux systems", description:
example: Examples.BaseGame.compatibility "Steam Deck/Proton compatibility rating indicating how well the game runs on Linux systems",
example: Examples.BaseGame.compatibility,
}), }),
}).openapi({
ref: "BaseGame",
description: "Detailed information about a game available in the Nestri library, including technical specifications and metadata",
example: Examples.BaseGame
}) })
.openapi({
ref: "BaseGame",
description:
"Detailed information about a game available in the Nestri library, including technical specifications and metadata",
example: Examples.BaseGame,
});
export type Info = z.infer<typeof Info>; export type Info = z.infer<typeof Info>;
@@ -70,7 +84,7 @@ export namespace BaseGame {
z.object({ z.object({
appID: Info.shape.id, appID: Info.shape.id,
url: z.string().url(), url: z.string().url(),
type: z.enum(ImageTypeEnum.enumValues) type: z.enum(ImageTypeEnum.enumValues),
}), }),
), ),
NewBoxArt: createEvent( NewBoxArt: createEvent(
@@ -91,24 +105,22 @@ export namespace BaseGame {
), ),
}; };
export const create = fn( export const create = fn(Info, (input) =>
Info, Database.transaction(async (tx) => {
(input) =>
createTransaction(async (tx) => {
const result = await tx const result = await tx
.select() .select()
.from(baseGamesTable) .from(baseGamesTable)
.where( .where(
and( and(
eq(baseGamesTable.id, input.id), eq(baseGamesTable.id, input.id),
isNull(baseGamesTable.timeDeleted) isNull(baseGamesTable.timeDeleted),
) ),
) )
.limit(1) .limit(1)
.execute() .execute()
.then(rows => rows.at(0)) .then((rows) => rows.at(0));
if (result) return result.id if (result) return result.id;
await tx await tx
.insert(baseGamesTable) .insert(baseGamesTable)
@@ -116,31 +128,26 @@ export namespace BaseGame {
.onConflictDoUpdate({ .onConflictDoUpdate({
target: baseGamesTable.id, target: baseGamesTable.id,
set: { set: {
timeDeleted: null timeDeleted: null,
} },
}) });
return input.id return input.id;
}) }),
) );
export const fromID = fn( export const fromID = fn(Info.shape.id, (id) =>
Info.shape.id, Database.transaction(async (tx) =>
(id) =>
useTransaction(async (tx) =>
tx tx
.select() .select()
.from(baseGamesTable) .from(baseGamesTable)
.where( .where(
and( and(eq(baseGamesTable.id, id), isNull(baseGamesTable.timeDeleted)),
eq(baseGamesTable.id, id),
isNull(baseGamesTable.timeDeleted)
)
) )
.limit(1) .limit(1)
.then(rows => rows.map(serialize).at(0)) .then((rows) => rows.map(serialize).at(0)),
) ),
) );
export function serialize( export function serialize(
input: typeof baseGamesTable.$inferSelect, input: typeof baseGamesTable.$inferSelect,

View File

@@ -1,66 +1,72 @@
import { z } from "zod"; import { z } from "zod";
import { fn } from "../utils"; import { fn } from "../utils";
import { Database } from "../drizzle";
import { Examples } from "../examples"; import { Examples } from "../examples";
import { eq, isNull, and } from "drizzle-orm"; import { eq, isNull, and } from "drizzle-orm";
import { createSelectSchema } from "drizzle-zod"; import { createSelectSchema } from "drizzle-zod";
import { categoriesTable } from "./categories.sql"; import { categoriesTable } from "./categories.sql";
import { createTransaction, useTransaction } from "../drizzle/transaction";
export namespace Categories { export namespace Categories {
const Category = z.object({ const Category = z.object({
slug: z.string().openapi({ slug: z.string().openapi({
description: "A URL-friendly unique identifier for the category", description: "A URL-friendly unique identifier for the category",
example: "action-adventure" example: "action-adventure",
}), }),
name: z.string().openapi({ name: z.string().openapi({
description: "The human-readable display name of the category", description: "The human-readable display name of the category",
example: "Action Adventure" example: "Action Adventure",
}) }),
}) });
export const Info = export const Info = z
z.object({ .object({
publishers: Category.array().openapi({ publishers: Category.array().openapi({
description: "List of companies or organizations responsible for publishing and distributing the game", description:
example: Examples.Categories.publishers "List of companies or organizations responsible for publishing and distributing the game",
example: Examples.Categories.publishers,
}), }),
developers: Category.array().openapi({ developers: Category.array().openapi({
description: "List of studios, teams, or individuals who created and developed the game", description:
example: Examples.Categories.developers "List of studios, teams, or individuals who created and developed the game",
example: Examples.Categories.developers,
}), }),
tags: Category.array().openapi({ tags: Category.array().openapi({
description: "User-defined labels that describe specific features, themes, or characteristics of the game", description:
example: Examples.Categories.tags "User-defined labels that describe specific features, themes, or characteristics of the game",
example: Examples.Categories.tags,
}), }),
genres: Category.array().openapi({ genres: Category.array().openapi({
description: "Primary classification categories that define the game's style and type of gameplay", description:
example: Examples.Categories.genres "Primary classification categories that define the game's style and type of gameplay",
example: Examples.Categories.genres,
}), }),
categories: Category.array().openapi({ categories: Category.array().openapi({
description: "Primary classification categories that define the game's categorisation on Steam", description:
example: Examples.Categories.genres "Primary classification categories that define the game's categorisation on Steam",
example: Examples.Categories.genres,
}), }),
franchises: Category.array().openapi({ franchises: Category.array().openapi({
description: "The franchise this game belongs belongs to on Steam", description: "The franchise this game belongs belongs to on Steam",
example: Examples.Categories.genres example: Examples.Categories.genres,
}), }),
}).openapi({
ref: "Categories",
description: "A comprehensive categorization system for games, including publishing details, development credits, and content classification",
example: Examples.Categories
}) })
.openapi({
ref: "Categories",
description:
"A comprehensive categorization system for games, including publishing details, development credits, and content classification",
example: Examples.Categories,
});
export type Info = z.infer<typeof Info>; export type Info = z.infer<typeof Info>;
export const InputInfo = createSelectSchema(categoriesTable) export const InputInfo = createSelectSchema(categoriesTable).omit({
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true }) timeCreated: true,
timeDeleted: true,
timeUpdated: true,
});
export const create = fn( export const create = fn(InputInfo, (input) =>
InputInfo, Database.transaction(async (tx) => {
(input) =>
createTransaction(async (tx) => {
const result = await tx const result = await tx
.select() .select()
.from(categoriesTable) .from(categoriesTable)
@@ -68,31 +74,29 @@ export namespace Categories {
and( and(
eq(categoriesTable.slug, input.slug), eq(categoriesTable.slug, input.slug),
eq(categoriesTable.type, input.type), eq(categoriesTable.type, input.type),
isNull(categoriesTable.timeDeleted) isNull(categoriesTable.timeDeleted),
) ),
) )
.limit(1) .limit(1)
.execute() .execute()
.then(rows => rows.at(0)) .then((rows) => rows.at(0));
if (result) return result.slug if (result) return result.slug;
await tx await tx
.insert(categoriesTable) .insert(categoriesTable)
.values(input) .values(input)
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [categoriesTable.slug, categoriesTable.type], target: [categoriesTable.slug, categoriesTable.type],
set: { timeDeleted: null } set: { timeDeleted: null },
}) });
return input.slug return input.slug;
}) }),
) );
export const get = fn( export const get = fn(InputInfo.pick({ slug: true, type: true }), (input) =>
InputInfo.pick({ slug: true, type: true }), Database.transaction((tx) =>
(input) =>
useTransaction((tx) =>
tx tx
.select() .select()
.from(categoriesTable) .from(categoriesTable)
@@ -100,29 +104,37 @@ export namespace Categories {
and( and(
eq(categoriesTable.slug, input.slug), eq(categoriesTable.slug, input.slug),
eq(categoriesTable.type, input.type), eq(categoriesTable.type, input.type),
isNull(categoriesTable.timeDeleted) isNull(categoriesTable.timeDeleted),
) ),
) )
.limit(1) .limit(1)
.execute() .execute()
.then(rows => serialize(rows)) .then((rows) => serialize(rows)),
) ),
) );
export function serialize( export function serialize(
input: typeof categoriesTable.$inferSelect[], input: (typeof categoriesTable.$inferSelect)[],
): z.infer<typeof Info> { ): z.infer<typeof Info> {
return input.reduce<Record<`${typeof categoriesTable.$inferSelect["type"]}s`, { slug: string; name: string }[]>>((acc, cat) => { return input.reduce<
const key = `${cat.type}s` as `${typeof cat.type}s` Record<
acc[key]!.push({ slug: cat.slug, name: cat.name }) `${(typeof categoriesTable.$inferSelect)["type"]}s`,
return acc { slug: string; name: string }[]
}, { >
>(
(acc, cat) => {
const key = `${cat.type}s` as `${typeof cat.type}s`;
acc[key]!.push({ slug: cat.slug, name: cat.name });
return acc;
},
{
tags: [], tags: [],
genres: [], genres: [],
publishers: [], publishers: [],
developers: [], developers: [],
categories: [], categories: [],
franchises: [] franchises: [],
}) },
);
} }
} }

View File

@@ -3,6 +3,7 @@ import { fn } from "../utils";
import { User } from "../user"; import { User } from "../user";
import { Steam } from "../steam"; import { Steam } from "../steam";
import { Actor } from "../actor"; import { Actor } from "../actor";
import { Database } from "../drizzle";
import { Examples } from "../examples"; import { Examples } from "../examples";
import { friendTable } from "./friend.sql"; import { friendTable } from "./friend.sql";
import { userTable } from "../user/user.sql"; import { userTable } from "../user/user.sql";
@@ -11,76 +12,70 @@ import { createSelectSchema } from "drizzle-zod";
import { and, eq, isNull, sql } from "drizzle-orm"; import { and, eq, isNull, sql } from "drizzle-orm";
import { groupBy, map, pipe, values } from "remeda"; import { groupBy, map, pipe, values } from "remeda";
import { ErrorCodes, VisibleError } from "../error"; import { ErrorCodes, VisibleError } from "../error";
import { createTransaction, useTransaction } from "../drizzle/transaction";
export namespace Friend { export namespace Friend {
export const Info = Steam.Info export const Info = Steam.Info.extend({
.extend({
user: User.Info.nullable().openapi({ user: User.Info.nullable().openapi({
description: "The user account that owns this Steam account", description: "The user account that owns this Steam account",
example: Examples.User example: Examples.User,
}) }),
}) }).openapi({
.openapi({
ref: "Friend", ref: "Friend",
description: "Represents a friend's information stored on Nestri", description: "Represents a friend's information stored on Nestri",
example: Examples.Friend, example: Examples.Friend,
}); });
export const InputInfo = createSelectSchema(friendTable).omit({
export const InputInfo = createSelectSchema(friendTable) timeCreated: true,
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true }) timeDeleted: true,
timeUpdated: true,
});
export type Info = z.infer<typeof Info>; export type Info = z.infer<typeof Info>;
export type InputInfo = z.infer<typeof InputInfo>; export type InputInfo = z.infer<typeof InputInfo>;
export const add = fn( export const add = fn(InputInfo.partial({ steamID: true }), async (input) =>
InputInfo.partial({ steamID: true }), Database.transaction(async (tx) => {
async (input) => const steamID = input.steamID ?? Actor.steamID();
createTransaction(async (tx) => {
const steamID = input.steamID ?? Actor.steamID()
if (steamID === input.friendSteamID) { if (steamID === input.friendSteamID) {
throw new VisibleError( throw new VisibleError(
"forbidden", "forbidden",
ErrorCodes.Validation.INVALID_PARAMETER, ErrorCodes.Validation.INVALID_PARAMETER,
"Cannot add yourself as a friend" "Cannot add yourself as a friend",
); );
} }
const results = const results = await tx
await tx
.select() .select()
.from(friendTable) .from(friendTable)
.where( .where(
and( and(
eq(friendTable.steamID, steamID), eq(friendTable.steamID, steamID),
eq(friendTable.friendSteamID, input.friendSteamID), eq(friendTable.friendSteamID, input.friendSteamID),
isNull(friendTable.timeDeleted) isNull(friendTable.timeDeleted),
),
) )
) .execute();
.execute()
if (results.length > 0) return null if (results.length > 0) return null;
await tx await tx
.insert(friendTable) .insert(friendTable)
.values({ .values({
steamID, steamID,
friendSteamID: input.friendSteamID friendSteamID: input.friendSteamID,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [friendTable.steamID, friendTable.friendSteamID], target: [friendTable.steamID, friendTable.friendSteamID],
set: { timeDeleted: null } set: { timeDeleted: null },
}) });
return steamID return steamID;
}), }),
) );
export const end = fn( export const end = fn(InputInfo, (input) =>
InputInfo, Database.transaction(async (tx) =>
(input) =>
useTransaction(async (tx) =>
tx tx
.update(friendTable) .update(friendTable)
.set({ timeDeleted: sql`now()` }) .set({ timeDeleted: sql`now()` })
@@ -88,74 +83,59 @@ export namespace Friend {
and( and(
eq(friendTable.steamID, input.steamID), eq(friendTable.steamID, input.steamID),
eq(friendTable.friendSteamID, input.friendSteamID), eq(friendTable.friendSteamID, input.friendSteamID),
) ),
) ),
) ),
) );
export const list = () => export const list = () =>
useTransaction(async (tx) => Database.transaction(async (tx) =>
tx tx
.select({ .select({
steam: steamTable, steam: steamTable,
user: userTable, user: userTable,
}) })
.from(friendTable) .from(friendTable)
.innerJoin( .innerJoin(steamTable, eq(friendTable.friendSteamID, steamTable.id))
steamTable, .leftJoin(userTable, eq(steamTable.userID, userTable.id))
eq(friendTable.friendSteamID, steamTable.id)
)
.leftJoin(
userTable,
eq(steamTable.userID, userTable.id)
)
.where( .where(
and( and(
eq(friendTable.steamID, Actor.steamID()), eq(friendTable.steamID, Actor.steamID()),
isNull(friendTable.timeDeleted) isNull(friendTable.timeDeleted),
) ),
) )
.limit(100) .limit(100)
.execute() .execute()
.then(rows => serialize(rows)) .then((rows) => serialize(rows)),
) );
export const fromFriendID = fn( export const fromFriendID = fn(
InputInfo.shape.friendSteamID, InputInfo.shape.friendSteamID,
(friendSteamID) => (friendSteamID) =>
useTransaction(async (tx) => Database.transaction(async (tx) =>
tx tx
.select({ .select({
steam: steamTable, steam: steamTable,
user: userTable, user: userTable,
}) })
.from(friendTable) .from(friendTable)
.innerJoin( .innerJoin(steamTable, eq(friendTable.friendSteamID, steamTable.id))
steamTable, .leftJoin(userTable, eq(steamTable.userID, userTable.id))
eq(friendTable.friendSteamID, steamTable.id)
)
.leftJoin(
userTable,
eq(steamTable.userID, userTable.id)
)
.where( .where(
and( and(
eq(friendTable.steamID, Actor.steamID()), eq(friendTable.steamID, Actor.steamID()),
eq(friendTable.friendSteamID, friendSteamID), eq(friendTable.friendSteamID, friendSteamID),
isNull(friendTable.timeDeleted) isNull(friendTable.timeDeleted),
) ),
) )
.limit(1) .limit(1)
.execute() .execute()
.then(rows => serialize(rows).at(0)) .then((rows) => serialize(rows).at(0)),
) ),
) );
export const areFriends = fn(InputInfo.shape.friendSteamID, (friendSteamID) =>
export const areFriends = fn( Database.transaction(async (tx) => {
InputInfo.shape.friendSteamID,
(friendSteamID) =>
useTransaction(async (tx) => {
const result = await tx const result = await tx
.select() .select()
.from(friendTable) .from(friendTable)
@@ -163,18 +143,21 @@ export namespace Friend {
and( and(
eq(friendTable.steamID, Actor.steamID()), eq(friendTable.steamID, Actor.steamID()),
eq(friendTable.friendSteamID, friendSteamID), eq(friendTable.friendSteamID, friendSteamID),
isNull(friendTable.timeDeleted) isNull(friendTable.timeDeleted),
) ),
) )
.limit(1) .limit(1)
.execute() .execute();
return result.length > 0 return result.length > 0;
}) }),
) );
export function serialize( export function serialize(
input: { user: typeof userTable.$inferSelect | null; steam: typeof steamTable.$inferSelect }[], input: {
user: typeof userTable.$inferSelect | null;
steam: typeof steamTable.$inferSelect;
}[],
): z.infer<typeof Info>[] { ): z.infer<typeof Info>[] {
return pipe( return pipe(
input, input,
@@ -182,9 +165,8 @@ export namespace Friend {
values(), values(),
map((group) => ({ map((group) => ({
...Steam.serialize(group[0].steam), ...Steam.serialize(group[0].steam),
user: group[0].user ? User.serialize(group[0].user!) : null user: group[0].user ? User.serialize(group[0].user!) : null,
})) })),
) );
} }
} }

View File

@@ -11,28 +11,29 @@ import { imagesTable } from "../images/images.sql";
import { baseGamesTable } from "../base-game/base-game.sql"; import { baseGamesTable } from "../base-game/base-game.sql";
import { groupBy, map, pipe, uniqueBy, values } from "remeda"; import { groupBy, map, pipe, uniqueBy, values } from "remeda";
import { categoriesTable } from "../categories/categories.sql"; import { categoriesTable } from "../categories/categories.sql";
import { createTransaction, useTransaction } from "../drizzle/transaction"; import { Database } from "../drizzle";
export namespace Game { export namespace Game {
export const Info = z export const Info = z
.intersection(BaseGame.Info, Categories.Info, Images.Info) .intersection(BaseGame.Info, Categories.Info, Images.Info)
.openapi({ .openapi({
ref: "Game", ref: "Game",
description: "Detailed information about a game available in the Nestri library, including technical specifications, categories and metadata", description:
example: Examples.Game "Detailed information about a game available in the Nestri library, including technical specifications, categories and metadata",
}) example: Examples.Game,
});
export type Info = z.infer<typeof Info>; export type Info = z.infer<typeof Info>;
export const InputInfo = createSelectSchema(gamesTable) export const InputInfo = createSelectSchema(gamesTable).omit({
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true }) timeCreated: true,
timeDeleted: true,
timeUpdated: true,
});
export const create = fn( export const create = fn(InputInfo, (input) =>
InputInfo, Database.transaction(async (tx) => {
(input) => const result = await tx
createTransaction(async (tx) => {
const result =
await tx
.select() .select()
.from(gamesTable) .from(gamesTable)
.where( .where(
@@ -40,90 +41,101 @@ export namespace Game {
eq(gamesTable.categorySlug, input.categorySlug), eq(gamesTable.categorySlug, input.categorySlug),
eq(gamesTable.categoryType, input.categoryType), eq(gamesTable.categoryType, input.categoryType),
eq(gamesTable.baseGameID, input.baseGameID), eq(gamesTable.baseGameID, input.baseGameID),
isNull(gamesTable.timeDeleted) isNull(gamesTable.timeDeleted),
) ),
) )
.limit(1) .limit(1)
.execute() .execute()
.then(rows => rows.at(0)) .then((rows) => rows.at(0));
if (result) return result.baseGameID if (result) return result.baseGameID;
await tx await tx
.insert(gamesTable) .insert(gamesTable)
.values(input) .values(input)
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [gamesTable.categorySlug, gamesTable.categoryType, gamesTable.baseGameID], target: [
set: { timeDeleted: null } gamesTable.categorySlug,
}) gamesTable.categoryType,
gamesTable.baseGameID,
],
set: { timeDeleted: null },
});
return input.baseGameID return input.baseGameID;
}) }),
) );
export const fromID = fn( export const fromID = fn(InputInfo.shape.baseGameID, (gameID) =>
InputInfo.shape.baseGameID, Database.transaction(async (tx) =>
(gameID) =>
useTransaction(async (tx) =>
tx tx
.select({ .select({
games: baseGamesTable, games: baseGamesTable,
categories: categoriesTable, categories: categoriesTable,
images: imagesTable images: imagesTable,
}) })
.from(gamesTable) .from(gamesTable)
.innerJoin(baseGamesTable, .innerJoin(baseGamesTable, eq(baseGamesTable.id, gamesTable.baseGameID))
eq(baseGamesTable.id, gamesTable.baseGameID) .leftJoin(
) categoriesTable,
.leftJoin(categoriesTable,
and( and(
eq(categoriesTable.slug, gamesTable.categorySlug), eq(categoriesTable.slug, gamesTable.categorySlug),
eq(categoriesTable.type, gamesTable.categoryType), eq(categoriesTable.type, gamesTable.categoryType),
),
) )
) .leftJoin(
.leftJoin(imagesTable, imagesTable,
and( and(
eq(imagesTable.baseGameID, gamesTable.baseGameID), eq(imagesTable.baseGameID, gamesTable.baseGameID),
isNull(imagesTable.timeDeleted), isNull(imagesTable.timeDeleted),
) ),
) )
.where( .where(
and( and(
eq(gamesTable.baseGameID, gameID), eq(gamesTable.baseGameID, gameID),
isNull(gamesTable.timeDeleted) isNull(gamesTable.timeDeleted),
) ),
) )
.execute() .execute()
.then((rows) => serialize(rows).at(0)) .then((rows) => serialize(rows).at(0)),
) ),
) );
export function serialize( export function serialize(
input: { games: typeof baseGamesTable.$inferSelect; categories: typeof categoriesTable.$inferSelect | null; images: typeof imagesTable.$inferSelect | null }[], input: {
games: typeof baseGamesTable.$inferSelect;
categories: typeof categoriesTable.$inferSelect | null;
images: typeof imagesTable.$inferSelect | null;
}[],
): z.infer<typeof Info>[] { ): z.infer<typeof Info>[] {
return pipe( return pipe(
input, input,
groupBy((row) => row.games.id), groupBy((row) => row.games.id),
values(), values(),
map((group) => { map((group) => {
const game = BaseGame.serialize(group[0].games) const game = BaseGame.serialize(group[0].games);
const cats = uniqueBy( const cats = uniqueBy(
group.map(r => r.categories).filter((c): c is typeof categoriesTable.$inferSelect => Boolean(c)), group
(c) => `${c.slug}:${c.type}` .map((r) => r.categories)
) .filter((c): c is typeof categoriesTable.$inferSelect =>
Boolean(c),
),
(c) => `${c.slug}:${c.type}`,
);
const imgs = uniqueBy( const imgs = uniqueBy(
group.map(r => r.images).filter((c): c is typeof imagesTable.$inferSelect => Boolean(c)), group
(c) => `${c.type}:${c.imageHash}:${c.position}` .map((r) => r.images)
) .filter((c): c is typeof imagesTable.$inferSelect => Boolean(c)),
const byType = Categories.serialize(cats) (c) => `${c.type}:${c.imageHash}:${c.position}`,
const byImg = Images.serialize(imgs) );
const byType = Categories.serialize(cats);
const byImg = Images.serialize(imgs);
return { return {
...game, ...game,
...byType, ...byType,
...byImg ...byImg,
};
}),
);
} }
})
)
}
} }

View File

@@ -1,90 +1,110 @@
import { z } from "zod"; import { z } from "zod";
import { fn } from "../utils"; import { fn } from "../utils";
import { Database } from "../drizzle";
import { Examples } from "../examples"; import { Examples } from "../examples";
import { createSelectSchema } from "drizzle-zod"; import { createSelectSchema } from "drizzle-zod";
import { createTransaction } from "../drizzle/transaction";
import { ImageColor, ImageDimensions, imagesTable } from "./images.sql"; import { ImageColor, ImageDimensions, imagesTable } from "./images.sql";
export namespace Images { export namespace Images {
const Image = z.object({ const Image = z.object({
hash: z.string().openapi({ hash: z.string().openapi({
description: "A unique cryptographic hash identifier for the image, used for deduplication and URL generation", description:
example: Examples.CommonImg[0].hash "A unique cryptographic hash identifier for the image, used for deduplication and URL generation",
example: Examples.CommonImg[0].hash,
}), }),
averageColor: ImageColor.openapi({ averageColor: ImageColor.openapi({
description: "The calculated dominant color of the image with light/dark classification, used for UI theming", description:
example: Examples.CommonImg[0].averageColor "The calculated dominant color of the image with light/dark classification, used for UI theming",
example: Examples.CommonImg[0].averageColor,
}), }),
dimensions: ImageDimensions.openapi({ dimensions: ImageDimensions.openapi({
description: "The width and height dimensions of the image in pixels", description: "The width and height dimensions of the image in pixels",
example: Examples.CommonImg[0].dimensions example: Examples.CommonImg[0].dimensions,
}), }),
fileSize: z.number().int().openapi({ fileSize: z.number().int().openapi({
description: "The size of the image file in bytes, used for storage and bandwidth calculations", description:
example: Examples.CommonImg[0].fileSize "The size of the image file in bytes, used for storage and bandwidth calculations",
}) example: Examples.CommonImg[0].fileSize,
}) }),
});
export const Info = z.object({ export const Info = z
.object({
screenshots: Image.array().openapi({ screenshots: Image.array().openapi({
description: "In-game captured images showing actual gameplay, user interface, and key moments", description:
example: Examples.Images.screenshots "In-game captured images showing actual gameplay, user interface, and key moments",
example: Examples.Images.screenshots,
}), }),
boxArts: Image.array().openapi({ boxArts: Image.array().openapi({
description: "Square 1:1 aspect ratio artwork, typically used for store listings and thumbnails", description:
example: Examples.Images.boxArts "Square 1:1 aspect ratio artwork, typically used for store listings and thumbnails",
example: Examples.Images.boxArts,
}), }),
posters: Image.array().openapi({ posters: Image.array().openapi({
description: "Vertical 2:3 aspect ratio promotional artwork, similar to movie posters", description:
example: Examples.Images.posters "Vertical 2:3 aspect ratio promotional artwork, similar to movie posters",
example: Examples.Images.posters,
}), }),
banners: Image.array().openapi({ banners: Image.array().openapi({
description: "Horizontal promotional artwork optimized for header displays and banners", description:
example: Examples.Images.banners "Horizontal promotional artwork optimized for header displays and banners",
example: Examples.Images.banners,
}), }),
heroArts: Image.array().openapi({ heroArts: Image.array().openapi({
description: "High-resolution, wide-format artwork designed for featured content and main entries", description:
example: Examples.Images.heroArts "High-resolution, wide-format artwork designed for featured content and main entries",
example: Examples.Images.heroArts,
}), }),
backdrops: Image.array().openapi({ backdrops: Image.array().openapi({
description: "Full-width backdrop images optimized for page layouts and decorative purposes", description:
example: Examples.Images.backdrops "Full-width backdrop images optimized for page layouts and decorative purposes",
example: Examples.Images.backdrops,
}), }),
logos: Image.array().openapi({ logos: Image.array().openapi({
description: "Official game logo artwork, typically with transparent backgrounds for flexible placement", description:
example: Examples.Images.logos "Official game logo artwork, typically with transparent backgrounds for flexible placement",
example: Examples.Images.logos,
}), }),
icons: Image.array().openapi({ icons: Image.array().openapi({
description: "Small-format identifiers used for application shortcuts and compact displays", description:
example: Examples.Images.icons "Small-format identifiers used for application shortcuts and compact displays",
example: Examples.Images.icons,
}), }),
}).openapi({
ref: "Images",
description: "Complete collection of game-related visual assets, including promotional materials, UI elements, and store assets",
example: Examples.Images
}) })
.openapi({
ref: "Images",
description:
"Complete collection of game-related visual assets, including promotional materials, UI elements, and store assets",
example: Examples.Images,
});
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>;
export const InputInfo = createSelectSchema(imagesTable) export const InputInfo = createSelectSchema(imagesTable).omit({
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true }) timeCreated: true,
timeDeleted: true,
timeUpdated: true,
});
export const create = fn( export const create = fn(InputInfo, (input) =>
InputInfo, Database.transaction(async (tx) =>
(input) =>
createTransaction(async (tx) =>
tx tx
.insert(imagesTable) .insert(imagesTable)
.values(input) .values(input)
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [imagesTable.imageHash, imagesTable.type, imagesTable.baseGameID, imagesTable.position], target: [
set: { timeDeleted: null } imagesTable.imageHash,
}) imagesTable.type,
) imagesTable.baseGameID,
) imagesTable.position,
],
set: { timeDeleted: null },
}),
),
);
export function serialize( export function serialize(
input: typeof imagesTable.$inferSelect[], input: (typeof imagesTable.$inferSelect)[],
): z.infer<typeof Info> { ): z.infer<typeof Info> {
return input return input
.sort((a, b) => { .sort((a, b) => {
@@ -93,18 +113,30 @@ export namespace Images {
} }
return a.type.localeCompare(b.type); return a.type.localeCompare(b.type);
}) })
.reduce<Record<`${typeof imagesTable.$inferSelect["type"]}s`, { hash: string; averageColor: ImageColor; dimensions: ImageDimensions; fileSize: number }[]>>((acc, img) => { .reduce<
const key = `${img.type}s` as `${typeof img.type}s` Record<
`${(typeof imagesTable.$inferSelect)["type"]}s`,
{
hash: string;
averageColor: ImageColor;
dimensions: ImageDimensions;
fileSize: number;
}[]
>
>(
(acc, img) => {
const key = `${img.type}s` as `${typeof img.type}s`;
if (Array.isArray(acc[key])) { if (Array.isArray(acc[key])) {
acc[key]!.push({ acc[key]!.push({
hash: img.imageHash, hash: img.imageHash,
averageColor: img.extractedColor, averageColor: img.extractedColor,
dimensions: img.dimensions, dimensions: img.dimensions,
fileSize: img.fileSize fileSize: img.fileSize,
}) });
} }
return acc return acc;
}, { },
{
screenshots: [], screenshots: [],
boxArts: [], boxArts: [],
banners: [], banners: [],
@@ -113,7 +145,7 @@ export namespace Images {
backdrops: [], backdrops: [],
icons: [], icons: [],
logos: [], logos: [],
}) },
);
} }
} }

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { fn } from "../utils"; import { fn } from "../utils";
import { Game } from "../game"; import { Game } from "../game";
import { Actor } from "../actor"; import { Actor } from "../actor";
import { Database } from "../drizzle";
import { createEvent } from "../event"; import { createEvent } from "../event";
import { gamesTable } from "../game/game.sql"; import { gamesTable } from "../game/game.sql";
import { createSelectSchema } from "drizzle-zod"; import { createSelectSchema } from "drizzle-zod";
@@ -10,11 +11,13 @@ import { imagesTable } from "../images/images.sql";
import { and, eq, isNull, sql } from "drizzle-orm"; import { and, eq, isNull, sql } from "drizzle-orm";
import { baseGamesTable } from "../base-game/base-game.sql"; import { baseGamesTable } from "../base-game/base-game.sql";
import { categoriesTable } from "../categories/categories.sql"; import { categoriesTable } from "../categories/categories.sql";
import { createTransaction, useTransaction } from "../drizzle/transaction";
export namespace Library { export namespace Library {
export const Info = createSelectSchema(steamLibraryTable) export const Info = createSelectSchema(steamLibraryTable).omit({
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true }) timeCreated: true,
timeDeleted: true,
timeUpdated: true,
});
export type Info = z.infer<typeof Info>; export type Info = z.infer<typeof Info>;
@@ -29,27 +32,24 @@ export namespace Library {
), ),
}; };
export const add = fn( export const add = fn(Info.partial({ ownerSteamID: true }), async (input) =>
Info.partial({ ownerSteamID: true }), Database.transaction(async (tx) => {
async (input) => const ownerSteamID = input.ownerSteamID ?? Actor.steamID();
createTransaction(async (tx) => { const result = await tx
const ownerSteamID = input.ownerSteamID ?? Actor.steamID()
const result =
await tx
.select() .select()
.from(steamLibraryTable) .from(steamLibraryTable)
.where( .where(
and( and(
eq(steamLibraryTable.baseGameID, input.baseGameID), eq(steamLibraryTable.baseGameID, input.baseGameID),
eq(steamLibraryTable.ownerSteamID, ownerSteamID), eq(steamLibraryTable.ownerSteamID, ownerSteamID),
isNull(steamLibraryTable.timeDeleted) isNull(steamLibraryTable.timeDeleted),
) ),
) )
.limit(1) .limit(1)
.execute() .execute()
.then(rows => rows.at(0)) .then((rows) => rows.at(0));
if (result) return result.baseGameID if (result) return result.baseGameID;
await tx await tx
.insert(steamLibraryTable) .insert(steamLibraryTable)
@@ -60,21 +60,21 @@ export namespace Library {
totalPlaytime: input.totalPlaytime, totalPlaytime: input.totalPlaytime,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [steamLibraryTable.ownerSteamID, steamLibraryTable.baseGameID], target: [
steamLibraryTable.ownerSteamID,
steamLibraryTable.baseGameID,
],
set: { set: {
timeDeleted: null, timeDeleted: null,
lastPlayed: input.lastPlayed, lastPlayed: input.lastPlayed,
totalPlaytime: input.totalPlaytime, totalPlaytime: input.totalPlaytime,
} },
}) });
}),
);
}) export const remove = fn(Info, (input) =>
) Database.transaction(async (tx) =>
export const remove = fn(
Info,
(input) =>
useTransaction(async (tx) =>
tx tx
.update(steamLibraryTable) .update(steamLibraryTable)
.set({ timeDeleted: sql`now()` }) .set({ timeDeleted: sql`now()` })
@@ -82,40 +82,37 @@ export namespace Library {
and( and(
eq(steamLibraryTable.ownerSteamID, input.ownerSteamID), eq(steamLibraryTable.ownerSteamID, input.ownerSteamID),
eq(steamLibraryTable.baseGameID, input.baseGameID), eq(steamLibraryTable.baseGameID, input.baseGameID),
) ),
) ),
) ),
) );
export const list = () => export const list = () =>
useTransaction(async (tx) => Database.transaction(async (tx) =>
tx tx
.select({ .select({
games: baseGamesTable, games: baseGamesTable,
categories: categoriesTable, categories: categoriesTable,
images: imagesTable images: imagesTable,
}) })
.from(steamLibraryTable) .from(steamLibraryTable)
.where( .where(
and( and(
eq(steamLibraryTable.ownerSteamID, Actor.steamID()), eq(steamLibraryTable.ownerSteamID, Actor.steamID()),
isNull(steamLibraryTable.timeDeleted) isNull(steamLibraryTable.timeDeleted),
) ),
) )
.innerJoin( .innerJoin(
baseGamesTable, baseGamesTable,
eq(baseGamesTable.id, steamLibraryTable.baseGameID), eq(baseGamesTable.id, steamLibraryTable.baseGameID),
) )
.leftJoin( .leftJoin(gamesTable, eq(gamesTable.baseGameID, baseGamesTable.id))
gamesTable,
eq(gamesTable.baseGameID, baseGamesTable.id),
)
.leftJoin( .leftJoin(
categoriesTable, categoriesTable,
and( and(
eq(categoriesTable.slug, gamesTable.categorySlug), eq(categoriesTable.slug, gamesTable.categorySlug),
eq(categoriesTable.type, gamesTable.categoryType), eq(categoriesTable.type, gamesTable.categoryType),
) ),
) )
// Joining imagesTable 1-N with gamesTable multiplies rows; the subsequent Game.serialize has to uniqueBy to undo this. // Joining imagesTable 1-N with gamesTable multiplies rows; the subsequent Game.serialize has to uniqueBy to undo this.
// For large libraries with many screenshots the Cartesian effect can significantly bloat the result and network payload. // For large libraries with many screenshots the Cartesian effect can significantly bloat the result and network payload.
@@ -129,10 +126,9 @@ export namespace Library {
and( and(
eq(imagesTable.baseGameID, gamesTable.baseGameID), eq(imagesTable.baseGameID, gamesTable.baseGameID),
isNull(imagesTable.timeDeleted), isNull(imagesTable.timeDeleted),
) ),
) )
.execute() .execute()
.then(rows => Game.serialize(rows)) .then((rows) => Game.serialize(rows)),
) );
} }

View File

@@ -4,55 +4,55 @@ import { Resource } from "sst";
import { Actor } from "../actor"; import { Actor } from "../actor";
import { bus } from "sst/aws/bus"; import { bus } from "sst/aws/bus";
import { Common } from "../common"; import { Common } from "../common";
import { Database } from "../drizzle";
import { Examples } from "../examples"; import { Examples } from "../examples";
import { createEvent } from "../event"; import { createEvent } from "../event";
import { eq, and, isNull, desc } from "drizzle-orm"; import { eq, and, isNull, desc } from "drizzle-orm";
import { steamTable, StatusEnum, Limitations } from "./steam.sql"; import { steamTable, StatusEnum, Limitations } from "./steam.sql";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
export namespace Steam { export namespace Steam {
export const Info = z export const Info = z
.object({ .object({
id: z.string().openapi({ id: z.string().openapi({
description: Common.IdDescription, description: Common.IdDescription,
example: Examples.SteamAccount.id example: Examples.SteamAccount.id,
}), }),
avatarHash: z.string().openapi({ avatarHash: z.string().openapi({
description: "The Steam avatar hash that this account owns", description: "The Steam avatar hash that this account owns",
example: Examples.SteamAccount.avatarHash example: Examples.SteamAccount.avatarHash,
}), }),
status: z.enum(StatusEnum.enumValues).openapi({ status: z.enum(StatusEnum.enumValues).openapi({
description: "The current connection status of this Steam account", description: "The current connection status of this Steam account",
example: Examples.SteamAccount.status example: Examples.SteamAccount.status,
}), }),
userID: z.string().nullable().openapi({ userID: z.string().nullable().openapi({
description: "The user id of which account owns this steam account", description: "The user id of which account owns this steam account",
example: Examples.SteamAccount.userID example: Examples.SteamAccount.userID,
}), }),
profileUrl: z.string().nullable().openapi({ profileUrl: z.string().nullable().openapi({
description: "The steam community url of this account", description: "The steam community url of this account",
example: Examples.SteamAccount.profileUrl example: Examples.SteamAccount.profileUrl,
}), }),
realName: z.string().nullable().openapi({ realName: z.string().nullable().openapi({
description: "The real name behind of this Steam account", description: "The real name behind of this Steam account",
example: Examples.SteamAccount.realName example: Examples.SteamAccount.realName,
}), }),
name: z.string().openapi({ name: z.string().openapi({
description: "The name used by this account", description: "The name used by this account",
example: Examples.SteamAccount.name example: Examples.SteamAccount.name,
}), }),
lastSyncedAt: z.date().openapi({ lastSyncedAt: z.date().openapi({
description: "The last time this account was synced to Steam", description: "The last time this account was synced to Steam",
example: Examples.SteamAccount.lastSyncedAt example: Examples.SteamAccount.lastSyncedAt,
}), }),
limitations: Limitations.openapi({ limitations: Limitations.openapi({
description: "The limitations bestowed on this Steam account by Steam", description: "The limitations bestowed on this Steam account by Steam",
example: Examples.SteamAccount.limitations example: Examples.SteamAccount.limitations,
}), }),
steamMemberSince: z.date().openapi({ steamMemberSince: z.date().openapi({
description: "When this Steam community account was created", description: "When this Steam community account was created",
example: Examples.SteamAccount.steamMemberSince example: Examples.SteamAccount.steamMemberSince,
}) }),
}) })
.openapi({ .openapi({
ref: "Steam", ref: "Steam",
@@ -74,44 +74,41 @@ export namespace Steam {
"steam_account.updated", "steam_account.updated",
z.object({ z.object({
steamID: Info.shape.id, steamID: Info.shape.id,
userID: Info.shape.userID userID: Info.shape.userID,
}), }),
) ),
}; };
export const create = fn( export const create = fn(
Info Info.extend({
.extend({
useUser: z.boolean(), useUser: z.boolean(),
}) }).partial({
.partial({
userID: true, userID: true,
status: true, status: true,
useUser: true, useUser: true,
lastSyncedAt: true lastSyncedAt: true,
}), }),
(input) => (input) =>
createTransaction(async (tx) => { Database.transaction(async (tx) => {
const accounts = const accounts = await tx
await tx
.select() .select()
.from(steamTable) .from(steamTable)
.where( .where(
and( and(isNull(steamTable.timeDeleted), eq(steamTable.id, input.id)),
isNull(steamTable.timeDeleted),
eq(steamTable.id, input.id)
)
) )
.execute() .execute()
.then((rows) => rows.map(serialize)) .then((rows) => rows.map(serialize));
// Update instead of create // Update instead of create
if (accounts.length > 0) return null if (accounts.length > 0) return null;
const userID = typeof input.userID === "string" ? input.userID : input.useUser ? Actor.userID() : null; const userID =
await tx typeof input.userID === "string"
.insert(steamTable) ? input.userID
.values({ : input.useUser
? Actor.userID()
: null;
await tx.insert(steamTable).values({
userID, userID,
id: input.id, id: input.id,
name: input.name, name: input.name,
@@ -122,13 +119,13 @@ export namespace Steam {
status: input.status ?? "offline", status: input.status ?? "offline",
steamMemberSince: input.steamMemberSince, steamMemberSince: input.steamMemberSince,
lastSyncedAt: input.lastSyncedAt ?? Common.utc(), lastSyncedAt: input.lastSyncedAt ?? Common.utc(),
}) });
await afterTx(async () => // await afterTx(async () =>
bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id }) // bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id })
); // );
return input.id return input.id;
}), }),
); );
@@ -136,47 +133,45 @@ export namespace Steam {
z z
.object({ .object({
userID: z.string(), userID: z.string(),
steamID: z.string() steamID: z.string(),
}) })
.partial({ .partial({
userID: true userID: true,
}), }),
async (input) => async (input) =>
createTransaction(async (tx) => { Database.transaction(async (tx) => {
const userID = input.userID ?? Actor.userID() const userID = input.userID ?? Actor.userID();
await tx await tx
.update(steamTable) .update(steamTable)
.set({ .set({
userID userID,
}) })
.where(eq(steamTable.id, input.steamID)); .where(eq(steamTable.id, input.steamID));
await afterTx(async () => // await afterTx(async () =>
bus.publish(Resource.Bus, Events.Updated, { userID, steamID: input.steamID }) // bus.publish(Resource.Bus, Events.Updated, { userID, steamID: input.steamID })
// );
return input.steamID;
}),
); );
return input.steamID export const fromUserID = fn(z.string().min(1), (userID) =>
}) Database.transaction((tx) =>
)
export const fromUserID = fn(
z.string().min(1),
(userID) =>
useTransaction((tx) =>
tx tx
.select() .select()
.from(steamTable) .from(steamTable)
.where(and(eq(steamTable.userID, userID), isNull(steamTable.timeDeleted))) .where(
and(eq(steamTable.userID, userID), isNull(steamTable.timeDeleted)),
)
.orderBy(desc(steamTable.timeCreated)) .orderBy(desc(steamTable.timeCreated))
.execute() .execute()
.then((rows) => rows.map(serialize)) .then((rows) => rows.map(serialize)),
) ),
) );
export const confirmOwnerShip = fn( export const confirmOwnerShip = fn(z.string().min(1), (userID) =>
z.string().min(1), Database.transaction((tx) =>
(userID) =>
useTransaction((tx) =>
tx tx
.select() .select()
.from(steamTable) .from(steamTable)
@@ -184,39 +179,42 @@ export namespace Steam {
and( and(
eq(steamTable.userID, userID), eq(steamTable.userID, userID),
eq(steamTable.id, Actor.steamID()), eq(steamTable.id, Actor.steamID()),
isNull(steamTable.timeDeleted) isNull(steamTable.timeDeleted),
) ),
) )
.orderBy(desc(steamTable.timeCreated)) .orderBy(desc(steamTable.timeCreated))
.execute() .execute()
.then((rows) => rows.map(serialize).at(0)) .then((rows) => rows.map(serialize).at(0)),
) ),
) );
export const fromSteamID = fn( export const fromSteamID = fn(z.string(), (steamID) =>
z.string(), Database.transaction((tx) =>
(steamID) =>
useTransaction((tx) =>
tx tx
.select() .select()
.from(steamTable) .from(steamTable)
.where(and(eq(steamTable.id, steamID), isNull(steamTable.timeDeleted))) .where(and(eq(steamTable.id, steamID), isNull(steamTable.timeDeleted)))
.orderBy(desc(steamTable.timeCreated)) .orderBy(desc(steamTable.timeCreated))
.execute() .execute()
.then((rows) => rows.map(serialize).at(0)) .then((rows) => rows.map(serialize).at(0)),
) ),
) );
export const list = () => export const list = () =>
useTransaction((tx) => Database.transaction((tx) =>
tx tx
.select() .select()
.from(steamTable) .from(steamTable)
.where(and(eq(steamTable.userID, Actor.userID()), isNull(steamTable.timeDeleted))) .where(
and(
eq(steamTable.userID, Actor.userID()),
isNull(steamTable.timeDeleted),
),
)
.orderBy(desc(steamTable.timeCreated)) .orderBy(desc(steamTable.timeCreated))
.execute() .execute()
.then((rows) => rows.map(serialize)) .then((rows) => rows.map(serialize)),
) );
export function serialize( export function serialize(
input: typeof steamTable.$inferSelect, input: typeof steamTable.$inferSelect,
@@ -234,5 +232,4 @@ export namespace Steam {
steamMemberSince: input.steamMemberSince, steamMemberSince: input.steamMemberSince,
}; };
} }
} }