From aba0bc3be1d7d08feef2e213dacf869601c981e9 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 20 Sep 2025 17:20:16 +0300 Subject: [PATCH] chore: Migrate to namespace --- cloud/packages/core/src/base-game/index.ts | 305 +++++++------- cloud/packages/core/src/categories/index.ts | 232 ++++++----- cloud/packages/core/src/friend/index.ts | 312 +++++++------- cloud/packages/core/src/game/index.ts | 230 ++++++----- cloud/packages/core/src/images/index.ts | 246 ++++++----- cloud/packages/core/src/library/index.ts | 234 ++++++----- cloud/packages/core/src/steam/index.ts | 431 ++++++++++---------- 7 files changed, 1014 insertions(+), 976 deletions(-) diff --git a/cloud/packages/core/src/base-game/index.ts b/cloud/packages/core/src/base-game/index.ts index 989b2880..6484e68e 100644 --- a/cloud/packages/core/src/base-game/index.ts +++ b/cloud/packages/core/src/base-game/index.ts @@ -5,158 +5,165 @@ import { Examples } from "../examples"; import { createEvent } from "../event"; import { eq, isNull, and } from "drizzle-orm"; import { ImageTypeEnum } from "../images/images.sql"; -import { createTransaction, useTransaction } from "../drizzle/transaction"; -import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum, Links } from "./base-game.sql"; +import { Database } from "../drizzle"; +import { + CompatibilityEnum, + baseGamesTable, + Size, + ControllerEnum, + Links, +} from "./base-game.sql"; export namespace BaseGame { - export const Info = z.object({ - id: z.string().openapi({ - description: Common.IdDescription, - example: Examples.BaseGame.id - }), - slug: z.string().openapi({ - description: "A URL-friendly unique identifier for the game, used in web addresses and API endpoints", - example: Examples.BaseGame.slug - }), - name: z.string().openapi({ - description: "The official title of the game as listed on Steam", - example: Examples.BaseGame.name - }), - size: Size.openapi({ - description: "Storage requirements in bytes: downloadSize represents the compressed download, and sizeOnDisk represents the installed size", - example: Examples.BaseGame.size - }), - releaseDate: z.date().openapi({ - description: "The initial public release date of the game on Steam", - example: Examples.BaseGame.releaseDate - }), - description: z.string().nullable().openapi({ - description: "A comprehensive overview of the game, including its features, storyline, and gameplay elements", - example: Examples.BaseGame.description - }), - score: z.number().openapi({ - description: "The aggregate user review score on Steam, represented as a percentage of positive reviews", - example: Examples.BaseGame.score - }), - links: Links - .nullable() - .openapi({ - description: "The social links of this game", - example: Examples.BaseGame.links - }), - primaryGenre: z.string().nullable().openapi({ - description: "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({ - description: "Indicates the level of gamepad/controller compatibility: 'Full', 'Partial', or 'Unkown' for no support", - example: Examples.BaseGame.controllerSupport - }), - compatibility: z.enum(CompatibilityEnum.enumValues).openapi({ - description: "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 + export const Info = z + .object({ + id: z.string().openapi({ + description: Common.IdDescription, + example: Examples.BaseGame.id, + }), + slug: z.string().openapi({ + description: + "A URL-friendly unique identifier for the game, used in web addresses and API endpoints", + example: Examples.BaseGame.slug, + }), + name: z.string().openapi({ + description: "The official title of the game as listed on Steam", + example: Examples.BaseGame.name, + }), + size: Size.openapi({ + description: + "Storage requirements in bytes: downloadSize represents the compressed download, and sizeOnDisk represents the installed size", + example: Examples.BaseGame.size, + }), + releaseDate: z.date().openapi({ + description: "The initial public release date of the game on Steam", + example: Examples.BaseGame.releaseDate, + }), + description: z.string().nullable().openapi({ + description: + "A comprehensive overview of the game, including its features, storyline, and gameplay elements", + example: Examples.BaseGame.description, + }), + score: z.number().openapi({ + description: + "The aggregate user review score on Steam, represented as a percentage of positive reviews", + example: Examples.BaseGame.score, + }), + links: Links.nullable().openapi({ + description: "The social links of this game", + example: Examples.BaseGame.links, + }), + primaryGenre: z.string().nullable().openapi({ + description: + "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({ + description: + "Indicates the level of gamepad/controller compatibility: 'Full', 'Partial', or 'Unkown' for no support", + example: Examples.BaseGame.controllerSupport, + }), + compatibility: z.enum(CompatibilityEnum.enumValues).openapi({ + description: + "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, + }); - export type Info = z.infer; + export type Info = z.infer; - export const Events = { - New: createEvent( - "new_image.save", - z.object({ - appID: Info.shape.id, - url: z.string().url(), - type: z.enum(ImageTypeEnum.enumValues) - }), - ), - NewBoxArt: createEvent( - "new_box_art_image.save", - z.object({ - appID: Info.shape.id, - logoUrl: z.string().url(), - backgroundUrl: z.string().url(), - }), - ), - NewHeroArt: createEvent( - "new_hero_art_image.save", - z.object({ - appID: Info.shape.id, - backdropUrl: z.string().url(), - screenshots: z.string().url().array(), - }), - ), + export const Events = { + New: createEvent( + "new_image.save", + z.object({ + appID: Info.shape.id, + url: z.string().url(), + type: z.enum(ImageTypeEnum.enumValues), + }), + ), + NewBoxArt: createEvent( + "new_box_art_image.save", + z.object({ + appID: Info.shape.id, + logoUrl: z.string().url(), + backgroundUrl: z.string().url(), + }), + ), + NewHeroArt: createEvent( + "new_hero_art_image.save", + z.object({ + appID: Info.shape.id, + backdropUrl: z.string().url(), + screenshots: z.string().url().array(), + }), + ), + }; + + export const create = fn(Info, (input) => + Database.transaction(async (tx) => { + const result = await tx + .select() + .from(baseGamesTable) + .where( + and( + eq(baseGamesTable.id, input.id), + isNull(baseGamesTable.timeDeleted), + ), + ) + .limit(1) + .execute() + .then((rows) => rows.at(0)); + + if (result) return result.id; + + await tx + .insert(baseGamesTable) + .values(input) + .onConflictDoUpdate({ + target: baseGamesTable.id, + set: { + timeDeleted: null, + }, + }); + + return input.id; + }), + ); + + export const fromID = fn(Info.shape.id, (id) => + Database.transaction(async (tx) => + tx + .select() + .from(baseGamesTable) + .where( + and(eq(baseGamesTable.id, id), isNull(baseGamesTable.timeDeleted)), + ) + .limit(1) + .then((rows) => rows.map(serialize).at(0)), + ), + ); + + export function serialize( + input: typeof baseGamesTable.$inferSelect, + ): z.infer { + return { + id: input.id, + name: input.name, + slug: input.slug, + size: input.size, + links: input.links, + score: input.score, + description: input.description, + releaseDate: input.releaseDate, + primaryGenre: input.primaryGenre, + compatibility: input.compatibility, + controllerSupport: input.controllerSupport, }; - - export const create = fn( - Info, - (input) => - createTransaction(async (tx) => { - const result = await tx - .select() - .from(baseGamesTable) - .where( - and( - eq(baseGamesTable.id, input.id), - isNull(baseGamesTable.timeDeleted) - ) - ) - .limit(1) - .execute() - .then(rows => rows.at(0)) - - if (result) return result.id - - await tx - .insert(baseGamesTable) - .values(input) - .onConflictDoUpdate({ - target: baseGamesTable.id, - set: { - timeDeleted: null - } - }) - - return input.id - }) - ) - - export const fromID = fn( - Info.shape.id, - (id) => - useTransaction(async (tx) => - tx - .select() - .from(baseGamesTable) - .where( - and( - eq(baseGamesTable.id, id), - isNull(baseGamesTable.timeDeleted) - ) - ) - .limit(1) - .then(rows => rows.map(serialize).at(0)) - ) - ) - - export function serialize( - input: typeof baseGamesTable.$inferSelect, - ): z.infer { - return { - id: input.id, - name: input.name, - slug: input.slug, - size: input.size, - links: input.links, - score: input.score, - description: input.description, - releaseDate: input.releaseDate, - primaryGenre: input.primaryGenre, - compatibility: input.compatibility, - controllerSupport: input.controllerSupport, - }; - } -} \ No newline at end of file + } +} diff --git a/cloud/packages/core/src/categories/index.ts b/cloud/packages/core/src/categories/index.ts index ff077f85..a947ad75 100644 --- a/cloud/packages/core/src/categories/index.ts +++ b/cloud/packages/core/src/categories/index.ts @@ -1,128 +1,140 @@ import { z } from "zod"; import { fn } from "../utils"; +import { Database } from "../drizzle"; import { Examples } from "../examples"; import { eq, isNull, and } from "drizzle-orm"; import { createSelectSchema } from "drizzle-zod"; import { categoriesTable } from "./categories.sql"; -import { createTransaction, useTransaction } from "../drizzle/transaction"; export namespace Categories { + const Category = z.object({ + slug: z.string().openapi({ + description: "A URL-friendly unique identifier for the category", + example: "action-adventure", + }), + name: z.string().openapi({ + description: "The human-readable display name of the category", + example: "Action Adventure", + }), + }); - const Category = z.object({ - slug: z.string().openapi({ - description: "A URL-friendly unique identifier for the category", - example: "action-adventure" - }), - name: z.string().openapi({ - description: "The human-readable display name of the category", - example: "Action Adventure" - }) + export const Info = z + .object({ + publishers: Category.array().openapi({ + description: + "List of companies or organizations responsible for publishing and distributing the game", + example: Examples.Categories.publishers, + }), + developers: Category.array().openapi({ + description: + "List of studios, teams, or individuals who created and developed the game", + example: Examples.Categories.developers, + }), + tags: Category.array().openapi({ + description: + "User-defined labels that describe specific features, themes, or characteristics of the game", + example: Examples.Categories.tags, + }), + genres: Category.array().openapi({ + description: + "Primary classification categories that define the game's style and type of gameplay", + example: Examples.Categories.genres, + }), + categories: Category.array().openapi({ + description: + "Primary classification categories that define the game's categorisation on Steam", + example: Examples.Categories.genres, + }), + franchises: Category.array().openapi({ + description: "The franchise this game belongs belongs to on Steam", + 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, + }); - export const Info = - z.object({ - publishers: Category.array().openapi({ - description: "List of companies or organizations responsible for publishing and distributing the game", - example: Examples.Categories.publishers - }), - developers: Category.array().openapi({ - description: "List of studios, teams, or individuals who created and developed the game", - example: Examples.Categories.developers - }), - tags: Category.array().openapi({ - description: "User-defined labels that describe specific features, themes, or characteristics of the game", - example: Examples.Categories.tags - }), - genres: Category.array().openapi({ - description: "Primary classification categories that define the game's style and type of gameplay", - example: Examples.Categories.genres - }), - categories: Category.array().openapi({ - description: "Primary classification categories that define the game's categorisation on Steam", - example: Examples.Categories.genres - }), - franchises: Category.array().openapi({ - description: "The franchise this game belongs belongs to on Steam", - example: Examples.Categories.genres - }), + export type Info = z.infer; - }).openapi({ - ref: "Categories", - description: "A comprehensive categorization system for games, including publishing details, development credits, and content classification", - example: Examples.Categories - }) + export const InputInfo = createSelectSchema(categoriesTable).omit({ + timeCreated: true, + timeDeleted: true, + timeUpdated: true, + }); - export type Info = z.infer; + export const create = fn(InputInfo, (input) => + Database.transaction(async (tx) => { + const result = await tx + .select() + .from(categoriesTable) + .where( + and( + eq(categoriesTable.slug, input.slug), + eq(categoriesTable.type, input.type), + isNull(categoriesTable.timeDeleted), + ), + ) + .limit(1) + .execute() + .then((rows) => rows.at(0)); - export const InputInfo = createSelectSchema(categoriesTable) - .omit({ timeCreated: true, timeDeleted: true, timeUpdated: true }) + if (result) return result.slug; - export const create = fn( - InputInfo, - (input) => - createTransaction(async (tx) => { - const result = await tx - .select() - .from(categoriesTable) - .where( - and( - eq(categoriesTable.slug, input.slug), - eq(categoriesTable.type, input.type), - isNull(categoriesTable.timeDeleted) - ) - ) - .limit(1) - .execute() - .then(rows => rows.at(0)) + await tx + .insert(categoriesTable) + .values(input) + .onConflictDoUpdate({ + target: [categoriesTable.slug, categoriesTable.type], + set: { timeDeleted: null }, + }); - if (result) return result.slug + return input.slug; + }), + ); - await tx - .insert(categoriesTable) - .values(input) - .onConflictDoUpdate({ - target: [categoriesTable.slug, categoriesTable.type], - set: { timeDeleted: null } - }) + export const get = fn(InputInfo.pick({ slug: true, type: true }), (input) => + Database.transaction((tx) => + tx + .select() + .from(categoriesTable) + .where( + and( + eq(categoriesTable.slug, input.slug), + eq(categoriesTable.type, input.type), + isNull(categoriesTable.timeDeleted), + ), + ) + .limit(1) + .execute() + .then((rows) => serialize(rows)), + ), + ); - return input.slug - }) - ) - - export const get = fn( - InputInfo.pick({ slug: true, type: true }), - (input) => - useTransaction((tx) => - tx - .select() - .from(categoriesTable) - .where( - and( - eq(categoriesTable.slug, input.slug), - eq(categoriesTable.type, input.type), - isNull(categoriesTable.timeDeleted) - ) - ) - .limit(1) - .execute() - .then(rows => serialize(rows)) - ) - ) - - export function serialize( - input: typeof categoriesTable.$inferSelect[], - ): z.infer { - return input.reduce>((acc, cat) => { - const key = `${cat.type}s` as `${typeof cat.type}s` - acc[key]!.push({ slug: cat.slug, name: cat.name }) - return acc - }, { - tags: [], - genres: [], - publishers: [], - developers: [], - categories: [], - franchises: [] - }) - } -} \ No newline at end of file + export function serialize( + input: (typeof categoriesTable.$inferSelect)[], + ): z.infer { + return input.reduce< + Record< + `${(typeof categoriesTable.$inferSelect)["type"]}s`, + { 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: [], + genres: [], + publishers: [], + developers: [], + categories: [], + franchises: [], + }, + ); + } +} diff --git a/cloud/packages/core/src/friend/index.ts b/cloud/packages/core/src/friend/index.ts index bb8ff38b..031e7ec4 100644 --- a/cloud/packages/core/src/friend/index.ts +++ b/cloud/packages/core/src/friend/index.ts @@ -3,6 +3,7 @@ import { fn } from "../utils"; import { User } from "../user"; import { Steam } from "../steam"; import { Actor } from "../actor"; +import { Database } from "../drizzle"; import { Examples } from "../examples"; import { friendTable } from "./friend.sql"; import { userTable } from "../user/user.sql"; @@ -11,180 +12,161 @@ import { createSelectSchema } from "drizzle-zod"; import { and, eq, isNull, sql } from "drizzle-orm"; import { groupBy, map, pipe, values } from "remeda"; import { ErrorCodes, VisibleError } from "../error"; -import { createTransaction, useTransaction } from "../drizzle/transaction"; export namespace Friend { - export const Info = Steam.Info - .extend({ - user: User.Info.nullable().openapi({ - description: "The user account that owns this Steam account", - example: Examples.User - }) + export const Info = Steam.Info.extend({ + user: User.Info.nullable().openapi({ + description: "The user account that owns this Steam account", + example: Examples.User, + }), + }).openapi({ + ref: "Friend", + description: "Represents a friend's information stored on Nestri", + example: Examples.Friend, + }); + + export const InputInfo = createSelectSchema(friendTable).omit({ + timeCreated: true, + timeDeleted: true, + timeUpdated: true, + }); + + export type Info = z.infer; + export type InputInfo = z.infer; + + export const add = fn(InputInfo.partial({ steamID: true }), async (input) => + Database.transaction(async (tx) => { + const steamID = input.steamID ?? Actor.steamID(); + if (steamID === input.friendSteamID) { + throw new VisibleError( + "forbidden", + ErrorCodes.Validation.INVALID_PARAMETER, + "Cannot add yourself as a friend", + ); + } + + const results = await tx + .select() + .from(friendTable) + .where( + and( + eq(friendTable.steamID, steamID), + eq(friendTable.friendSteamID, input.friendSteamID), + isNull(friendTable.timeDeleted), + ), + ) + .execute(); + + if (results.length > 0) return null; + + await tx + .insert(friendTable) + .values({ + steamID, + friendSteamID: input.friendSteamID, }) - .openapi({ - ref: "Friend", - description: "Represents a friend's information stored on Nestri", - example: Examples.Friend, + .onConflictDoUpdate({ + target: [friendTable.steamID, friendTable.friendSteamID], + set: { timeDeleted: null }, }); + return steamID; + }), + ); - export const InputInfo = createSelectSchema(friendTable) - .omit({ timeCreated: true, timeDeleted: true, timeUpdated: true }) + export const end = fn(InputInfo, (input) => + Database.transaction(async (tx) => + tx + .update(friendTable) + .set({ timeDeleted: sql`now()` }) + .where( + and( + eq(friendTable.steamID, input.steamID), + eq(friendTable.friendSteamID, input.friendSteamID), + ), + ), + ), + ); - export type Info = z.infer; - export type InputInfo = z.infer; - - export const add = fn( - InputInfo.partial({ steamID: true }), - async (input) => - createTransaction(async (tx) => { - const steamID = input.steamID ?? Actor.steamID() - if (steamID === input.friendSteamID) { - throw new VisibleError( - "forbidden", - ErrorCodes.Validation.INVALID_PARAMETER, - "Cannot add yourself as a friend" - ); - } - - const results = - await tx - .select() - .from(friendTable) - .where( - and( - eq(friendTable.steamID, steamID), - eq(friendTable.friendSteamID, input.friendSteamID), - isNull(friendTable.timeDeleted) - ) - ) - .execute() - - if (results.length > 0) return null - - await tx - .insert(friendTable) - .values({ - steamID, - friendSteamID: input.friendSteamID - }) - .onConflictDoUpdate({ - target: [friendTable.steamID, friendTable.friendSteamID], - set: { timeDeleted: null } - }) - - return steamID - }), - ) - - export const end = fn( - InputInfo, - (input) => - useTransaction(async (tx) => - tx - .update(friendTable) - .set({ timeDeleted: sql`now()` }) - .where( - and( - eq(friendTable.steamID, input.steamID), - eq(friendTable.friendSteamID, input.friendSteamID), - ) - ) - ) - ) - - export const list = () => - useTransaction(async (tx) => - tx - .select({ - steam: steamTable, - user: userTable, - }) - .from(friendTable) - .innerJoin( - steamTable, - eq(friendTable.friendSteamID, steamTable.id) - ) - .leftJoin( - userTable, - eq(steamTable.userID, userTable.id) - ) - .where( - and( - eq(friendTable.steamID, Actor.steamID()), - isNull(friendTable.timeDeleted) - ) - ) - .limit(100) - .execute() - .then(rows => serialize(rows)) + export const list = () => + Database.transaction(async (tx) => + tx + .select({ + steam: steamTable, + user: userTable, + }) + .from(friendTable) + .innerJoin(steamTable, eq(friendTable.friendSteamID, steamTable.id)) + .leftJoin(userTable, eq(steamTable.userID, userTable.id)) + .where( + and( + eq(friendTable.steamID, Actor.steamID()), + isNull(friendTable.timeDeleted), + ), ) + .limit(100) + .execute() + .then((rows) => serialize(rows)), + ); - export const fromFriendID = fn( - InputInfo.shape.friendSteamID, - (friendSteamID) => - useTransaction(async (tx) => - tx - .select({ - steam: steamTable, - user: userTable, - }) - .from(friendTable) - .innerJoin( - steamTable, - eq(friendTable.friendSteamID, steamTable.id) - ) - .leftJoin( - userTable, - eq(steamTable.userID, userTable.id) - ) - .where( - and( - eq(friendTable.steamID, Actor.steamID()), - eq(friendTable.friendSteamID, friendSteamID), - isNull(friendTable.timeDeleted) - ) - ) - .limit(1) - .execute() - .then(rows => serialize(rows).at(0)) - ) - ) + export const fromFriendID = fn( + InputInfo.shape.friendSteamID, + (friendSteamID) => + Database.transaction(async (tx) => + tx + .select({ + steam: steamTable, + user: userTable, + }) + .from(friendTable) + .innerJoin(steamTable, eq(friendTable.friendSteamID, steamTable.id)) + .leftJoin(userTable, eq(steamTable.userID, userTable.id)) + .where( + and( + eq(friendTable.steamID, Actor.steamID()), + eq(friendTable.friendSteamID, friendSteamID), + isNull(friendTable.timeDeleted), + ), + ) + .limit(1) + .execute() + .then((rows) => serialize(rows).at(0)), + ), + ); - - export const areFriends = fn( - InputInfo.shape.friendSteamID, - (friendSteamID) => - useTransaction(async (tx) => { - const result = await tx - .select() - .from(friendTable) - .where( - and( - eq(friendTable.steamID, Actor.steamID()), - eq(friendTable.friendSteamID, friendSteamID), - isNull(friendTable.timeDeleted) - ) - ) - .limit(1) - .execute() - - return result.length > 0 - }) - ) - - export function serialize( - input: { user: typeof userTable.$inferSelect | null; steam: typeof steamTable.$inferSelect }[], - ): z.infer[] { - return pipe( - input, - groupBy((row) => row.steam.id), - values(), - map((group) => ({ - ...Steam.serialize(group[0].steam), - user: group[0].user ? User.serialize(group[0].user!) : null - })) + export const areFriends = fn(InputInfo.shape.friendSteamID, (friendSteamID) => + Database.transaction(async (tx) => { + const result = await tx + .select() + .from(friendTable) + .where( + and( + eq(friendTable.steamID, Actor.steamID()), + eq(friendTable.friendSteamID, friendSteamID), + isNull(friendTable.timeDeleted), + ), ) - } + .limit(1) + .execute(); -} \ No newline at end of file + return result.length > 0; + }), + ); + + export function serialize( + input: { + user: typeof userTable.$inferSelect | null; + steam: typeof steamTable.$inferSelect; + }[], + ): z.infer[] { + return pipe( + input, + groupBy((row) => row.steam.id), + values(), + map((group) => ({ + ...Steam.serialize(group[0].steam), + user: group[0].user ? User.serialize(group[0].user!) : null, + })), + ); + } +} diff --git a/cloud/packages/core/src/game/index.ts b/cloud/packages/core/src/game/index.ts index 4358ca6e..bf1b36b3 100644 --- a/cloud/packages/core/src/game/index.ts +++ b/cloud/packages/core/src/game/index.ts @@ -11,119 +11,131 @@ import { imagesTable } from "../images/images.sql"; import { baseGamesTable } from "../base-game/base-game.sql"; import { groupBy, map, pipe, uniqueBy, values } from "remeda"; import { categoriesTable } from "../categories/categories.sql"; -import { createTransaction, useTransaction } from "../drizzle/transaction"; +import { Database } from "../drizzle"; export namespace Game { - export const Info = z - .intersection(BaseGame.Info, Categories.Info, Images.Info) - .openapi({ - ref: "Game", - description: "Detailed information about a game available in the Nestri library, including technical specifications, categories and metadata", - example: Examples.Game - }) + export const Info = z + .intersection(BaseGame.Info, Categories.Info, Images.Info) + .openapi({ + ref: "Game", + description: + "Detailed information about a game available in the Nestri library, including technical specifications, categories and metadata", + example: Examples.Game, + }); - export type Info = z.infer; + export type Info = z.infer; - export const InputInfo = createSelectSchema(gamesTable) - .omit({ timeCreated: true, timeDeleted: true, timeUpdated: true }) + export const InputInfo = createSelectSchema(gamesTable).omit({ + timeCreated: true, + timeDeleted: true, + timeUpdated: true, + }); - export const create = fn( - InputInfo, - (input) => - createTransaction(async (tx) => { - const result = - await tx - .select() - .from(gamesTable) - .where( - and( - eq(gamesTable.categorySlug, input.categorySlug), - eq(gamesTable.categoryType, input.categoryType), - eq(gamesTable.baseGameID, input.baseGameID), - isNull(gamesTable.timeDeleted) - ) - ) - .limit(1) - .execute() - .then(rows => rows.at(0)) - - if (result) return result.baseGameID - - await tx - .insert(gamesTable) - .values(input) - .onConflictDoUpdate({ - target: [gamesTable.categorySlug, gamesTable.categoryType, gamesTable.baseGameID], - set: { timeDeleted: null } - }) - - return input.baseGameID - }) - ) - - export const fromID = fn( - InputInfo.shape.baseGameID, - (gameID) => - useTransaction(async (tx) => - tx - .select({ - games: baseGamesTable, - categories: categoriesTable, - images: imagesTable - }) - .from(gamesTable) - .innerJoin(baseGamesTable, - eq(baseGamesTable.id, gamesTable.baseGameID) - ) - .leftJoin(categoriesTable, - and( - eq(categoriesTable.slug, gamesTable.categorySlug), - eq(categoriesTable.type, gamesTable.categoryType), - ) - ) - .leftJoin(imagesTable, - and( - eq(imagesTable.baseGameID, gamesTable.baseGameID), - isNull(imagesTable.timeDeleted), - ) - ) - .where( - and( - eq(gamesTable.baseGameID, gameID), - isNull(gamesTable.timeDeleted) - ) - ) - .execute() - .then((rows) => serialize(rows).at(0)) - ) - ) - - export function serialize( - input: { games: typeof baseGamesTable.$inferSelect; categories: typeof categoriesTable.$inferSelect | null; images: typeof imagesTable.$inferSelect | null }[], - ): z.infer[] { - return pipe( - input, - groupBy((row) => row.games.id), - values(), - map((group) => { - const game = BaseGame.serialize(group[0].games) - const cats = uniqueBy( - group.map(r => r.categories).filter((c): c is typeof categoriesTable.$inferSelect => Boolean(c)), - (c) => `${c.slug}:${c.type}` - ) - const imgs = uniqueBy( - group.map(r => r.images).filter((c): c is typeof imagesTable.$inferSelect => Boolean(c)), - (c) => `${c.type}:${c.imageHash}:${c.position}` - ) - const byType = Categories.serialize(cats) - const byImg = Images.serialize(imgs) - return { - ...game, - ...byType, - ...byImg - } - }) + export const create = fn(InputInfo, (input) => + Database.transaction(async (tx) => { + const result = await tx + .select() + .from(gamesTable) + .where( + and( + eq(gamesTable.categorySlug, input.categorySlug), + eq(gamesTable.categoryType, input.categoryType), + eq(gamesTable.baseGameID, input.baseGameID), + isNull(gamesTable.timeDeleted), + ), ) - } + .limit(1) + .execute() + .then((rows) => rows.at(0)); -} \ No newline at end of file + if (result) return result.baseGameID; + + await tx + .insert(gamesTable) + .values(input) + .onConflictDoUpdate({ + target: [ + gamesTable.categorySlug, + gamesTable.categoryType, + gamesTable.baseGameID, + ], + set: { timeDeleted: null }, + }); + + return input.baseGameID; + }), + ); + + export const fromID = fn(InputInfo.shape.baseGameID, (gameID) => + Database.transaction(async (tx) => + tx + .select({ + games: baseGamesTable, + categories: categoriesTable, + images: imagesTable, + }) + .from(gamesTable) + .innerJoin(baseGamesTable, eq(baseGamesTable.id, gamesTable.baseGameID)) + .leftJoin( + categoriesTable, + and( + eq(categoriesTable.slug, gamesTable.categorySlug), + eq(categoriesTable.type, gamesTable.categoryType), + ), + ) + .leftJoin( + imagesTable, + and( + eq(imagesTable.baseGameID, gamesTable.baseGameID), + isNull(imagesTable.timeDeleted), + ), + ) + .where( + and( + eq(gamesTable.baseGameID, gameID), + isNull(gamesTable.timeDeleted), + ), + ) + .execute() + .then((rows) => serialize(rows).at(0)), + ), + ); + + export function serialize( + input: { + games: typeof baseGamesTable.$inferSelect; + categories: typeof categoriesTable.$inferSelect | null; + images: typeof imagesTable.$inferSelect | null; + }[], + ): z.infer[] { + return pipe( + input, + groupBy((row) => row.games.id), + values(), + map((group) => { + const game = BaseGame.serialize(group[0].games); + const cats = uniqueBy( + group + .map((r) => r.categories) + .filter((c): c is typeof categoriesTable.$inferSelect => + Boolean(c), + ), + (c) => `${c.slug}:${c.type}`, + ); + const imgs = uniqueBy( + group + .map((r) => r.images) + .filter((c): c is typeof imagesTable.$inferSelect => Boolean(c)), + (c) => `${c.type}:${c.imageHash}:${c.position}`, + ); + const byType = Categories.serialize(cats); + const byImg = Images.serialize(imgs); + return { + ...game, + ...byType, + ...byImg, + }; + }), + ); + } +} diff --git a/cloud/packages/core/src/images/index.ts b/cloud/packages/core/src/images/index.ts index 1f3d8988..8329566d 100644 --- a/cloud/packages/core/src/images/index.ts +++ b/cloud/packages/core/src/images/index.ts @@ -1,119 +1,151 @@ import { z } from "zod"; import { fn } from "../utils"; +import { Database } from "../drizzle"; import { Examples } from "../examples"; import { createSelectSchema } from "drizzle-zod"; -import { createTransaction } from "../drizzle/transaction"; import { ImageColor, ImageDimensions, imagesTable } from "./images.sql"; export namespace Images { - const Image = z.object({ - hash: z.string().openapi({ - description: "A unique cryptographic hash identifier for the image, used for deduplication and URL generation", - example: Examples.CommonImg[0].hash - }), - averageColor: ImageColor.openapi({ - description: "The calculated dominant color of the image with light/dark classification, used for UI theming", - example: Examples.CommonImg[0].averageColor - }), - dimensions: ImageDimensions.openapi({ - description: "The width and height dimensions of the image in pixels", - example: Examples.CommonImg[0].dimensions - }), - fileSize: z.number().int().openapi({ - description: "The size of the image file in bytes, used for storage and bandwidth calculations", - example: Examples.CommonImg[0].fileSize - }) + const Image = z.object({ + hash: z.string().openapi({ + description: + "A unique cryptographic hash identifier for the image, used for deduplication and URL generation", + example: Examples.CommonImg[0].hash, + }), + averageColor: ImageColor.openapi({ + description: + "The calculated dominant color of the image with light/dark classification, used for UI theming", + example: Examples.CommonImg[0].averageColor, + }), + dimensions: ImageDimensions.openapi({ + description: "The width and height dimensions of the image in pixels", + example: Examples.CommonImg[0].dimensions, + }), + fileSize: z.number().int().openapi({ + description: + "The size of the image file in bytes, used for storage and bandwidth calculations", + example: Examples.CommonImg[0].fileSize, + }), + }); + + export const Info = z + .object({ + screenshots: Image.array().openapi({ + description: + "In-game captured images showing actual gameplay, user interface, and key moments", + example: Examples.Images.screenshots, + }), + boxArts: Image.array().openapi({ + description: + "Square 1:1 aspect ratio artwork, typically used for store listings and thumbnails", + example: Examples.Images.boxArts, + }), + posters: Image.array().openapi({ + description: + "Vertical 2:3 aspect ratio promotional artwork, similar to movie posters", + example: Examples.Images.posters, + }), + banners: Image.array().openapi({ + description: + "Horizontal promotional artwork optimized for header displays and banners", + example: Examples.Images.banners, + }), + heroArts: Image.array().openapi({ + description: + "High-resolution, wide-format artwork designed for featured content and main entries", + example: Examples.Images.heroArts, + }), + backdrops: Image.array().openapi({ + description: + "Full-width backdrop images optimized for page layouts and decorative purposes", + example: Examples.Images.backdrops, + }), + logos: Image.array().openapi({ + description: + "Official game logo artwork, typically with transparent backgrounds for flexible placement", + example: Examples.Images.logos, + }), + icons: Image.array().openapi({ + description: + "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, + }); - export const Info = z.object({ - screenshots: Image.array().openapi({ - description: "In-game captured images showing actual gameplay, user interface, and key moments", - example: Examples.Images.screenshots - }), - boxArts: Image.array().openapi({ - description: "Square 1:1 aspect ratio artwork, typically used for store listings and thumbnails", - example: Examples.Images.boxArts - }), - posters: Image.array().openapi({ - description: "Vertical 2:3 aspect ratio promotional artwork, similar to movie posters", - example: Examples.Images.posters - }), - banners: Image.array().openapi({ - description: "Horizontal promotional artwork optimized for header displays and banners", - example: Examples.Images.banners - }), - heroArts: Image.array().openapi({ - description: "High-resolution, wide-format artwork designed for featured content and main entries", - example: Examples.Images.heroArts - }), - backdrops: Image.array().openapi({ - description: "Full-width backdrop images optimized for page layouts and decorative purposes", - example: Examples.Images.backdrops - }), - logos: Image.array().openapi({ - description: "Official game logo artwork, typically with transparent backgrounds for flexible placement", - example: Examples.Images.logos - }), - icons: Image.array().openapi({ - description: "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 - }) + export type Info = z.infer; - export type Info = z.infer + export const InputInfo = createSelectSchema(imagesTable).omit({ + timeCreated: true, + timeDeleted: true, + timeUpdated: true, + }); - export const InputInfo = createSelectSchema(imagesTable) - .omit({ timeCreated: true, timeDeleted: true, timeUpdated: true }) + export const create = fn(InputInfo, (input) => + Database.transaction(async (tx) => + tx + .insert(imagesTable) + .values(input) + .onConflictDoUpdate({ + target: [ + imagesTable.imageHash, + imagesTable.type, + imagesTable.baseGameID, + imagesTable.position, + ], + set: { timeDeleted: null }, + }), + ), + ); - export const create = fn( - InputInfo, - (input) => - createTransaction(async (tx) => - tx - .insert(imagesTable) - .values(input) - .onConflictDoUpdate({ - target: [imagesTable.imageHash, imagesTable.type, imagesTable.baseGameID, imagesTable.position], - set: { timeDeleted: null } - }) - ) - ) - - export function serialize( - input: typeof imagesTable.$inferSelect[], - ): z.infer { - return input - .sort((a, b) => { - if (a.type === b.type) { - return a.position - b.position; - } - return a.type.localeCompare(b.type); - }) - .reduce>((acc, img) => { - const key = `${img.type}s` as `${typeof img.type}s` - if (Array.isArray(acc[key])) { - acc[key]!.push({ - hash: img.imageHash, - averageColor: img.extractedColor, - dimensions: img.dimensions, - fileSize: img.fileSize - }) - } - return acc - }, { - screenshots: [], - boxArts: [], - banners: [], - heroArts: [], - posters: [], - backdrops: [], - icons: [], - logos: [], - }) - } - -} \ No newline at end of file + export function serialize( + input: (typeof imagesTable.$inferSelect)[], + ): z.infer { + return input + .sort((a, b) => { + if (a.type === b.type) { + return a.position - b.position; + } + return a.type.localeCompare(b.type); + }) + .reduce< + 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])) { + acc[key]!.push({ + hash: img.imageHash, + averageColor: img.extractedColor, + dimensions: img.dimensions, + fileSize: img.fileSize, + }); + } + return acc; + }, + { + screenshots: [], + boxArts: [], + banners: [], + heroArts: [], + posters: [], + backdrops: [], + icons: [], + logos: [], + }, + ); + } +} diff --git a/cloud/packages/core/src/library/index.ts b/cloud/packages/core/src/library/index.ts index fe5e28e5..2c08ec6b 100644 --- a/cloud/packages/core/src/library/index.ts +++ b/cloud/packages/core/src/library/index.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { fn } from "../utils"; import { Game } from "../game"; import { Actor } from "../actor"; +import { Database } from "../drizzle"; import { createEvent } from "../event"; import { gamesTable } from "../game/game.sql"; import { createSelectSchema } from "drizzle-zod"; @@ -10,129 +11,124 @@ import { imagesTable } from "../images/images.sql"; import { and, eq, isNull, sql } from "drizzle-orm"; import { baseGamesTable } from "../base-game/base-game.sql"; import { categoriesTable } from "../categories/categories.sql"; -import { createTransaction, useTransaction } from "../drizzle/transaction"; export namespace Library { - export const Info = createSelectSchema(steamLibraryTable) - .omit({ timeCreated: true, timeDeleted: true, timeUpdated: true }) + export const Info = createSelectSchema(steamLibraryTable).omit({ + timeCreated: true, + timeDeleted: true, + timeUpdated: true, + }); - export type Info = z.infer; + export type Info = z.infer; - export const Events = { - Add: createEvent( - "library.add", - z.object({ - appID: z.number(), - lastPlayed: z.date().nullable(), - totalPlaytime: z.number(), - }), - ), - }; + export const Events = { + Add: createEvent( + "library.add", + z.object({ + appID: z.number(), + lastPlayed: z.date().nullable(), + totalPlaytime: z.number(), + }), + ), + }; - export const add = fn( - Info.partial({ ownerSteamID: true }), - async (input) => - createTransaction(async (tx) => { - const ownerSteamID = input.ownerSteamID ?? Actor.steamID() - const result = - await tx - .select() - .from(steamLibraryTable) - .where( - and( - eq(steamLibraryTable.baseGameID, input.baseGameID), - eq(steamLibraryTable.ownerSteamID, ownerSteamID), - isNull(steamLibraryTable.timeDeleted) - ) - ) - .limit(1) - .execute() - .then(rows => rows.at(0)) - - if (result) return result.baseGameID - - await tx - .insert(steamLibraryTable) - .values({ - ownerSteamID: ownerSteamID, - baseGameID: input.baseGameID, - lastPlayed: input.lastPlayed, - totalPlaytime: input.totalPlaytime, - }) - .onConflictDoUpdate({ - target: [steamLibraryTable.ownerSteamID, steamLibraryTable.baseGameID], - set: { - timeDeleted: null, - lastPlayed: input.lastPlayed, - totalPlaytime: input.totalPlaytime, - } - }) - - }) - ) - - export const remove = fn( - Info, - (input) => - useTransaction(async (tx) => - tx - .update(steamLibraryTable) - .set({ timeDeleted: sql`now()` }) - .where( - and( - eq(steamLibraryTable.ownerSteamID, input.ownerSteamID), - eq(steamLibraryTable.baseGameID, input.baseGameID), - ) - ) - ) - ) - - export const list = () => - useTransaction(async (tx) => - tx - .select({ - games: baseGamesTable, - categories: categoriesTable, - images: imagesTable - }) - .from(steamLibraryTable) - .where( - and( - eq(steamLibraryTable.ownerSteamID, Actor.steamID()), - isNull(steamLibraryTable.timeDeleted) - ) - ) - .innerJoin( - baseGamesTable, - eq(baseGamesTable.id, steamLibraryTable.baseGameID), - ) - .leftJoin( - gamesTable, - eq(gamesTable.baseGameID, baseGamesTable.id), - ) - .leftJoin( - categoriesTable, - and( - eq(categoriesTable.slug, gamesTable.categorySlug), - eq(categoriesTable.type, gamesTable.categoryType), - ) - ) - // 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. - // One option is to aggregate the images in SQL before joining to keep exactly one row per game: - // .leftJoin( - // sql`(SELECT * FROM images WHERE base_game_id = ${gamesTable.baseGameID} AND time_deleted IS NULL ORDER BY type, position)`.as("images"), - // sql`TRUE` - // ) - .leftJoin( - imagesTable, - and( - eq(imagesTable.baseGameID, gamesTable.baseGameID), - isNull(imagesTable.timeDeleted), - ) - ) - .execute() - .then(rows => Game.serialize(rows)) + export const add = fn(Info.partial({ ownerSteamID: true }), async (input) => + Database.transaction(async (tx) => { + const ownerSteamID = input.ownerSteamID ?? Actor.steamID(); + const result = await tx + .select() + .from(steamLibraryTable) + .where( + and( + eq(steamLibraryTable.baseGameID, input.baseGameID), + eq(steamLibraryTable.ownerSteamID, ownerSteamID), + isNull(steamLibraryTable.timeDeleted), + ), ) + .limit(1) + .execute() + .then((rows) => rows.at(0)); -} \ No newline at end of file + if (result) return result.baseGameID; + + await tx + .insert(steamLibraryTable) + .values({ + ownerSteamID: ownerSteamID, + baseGameID: input.baseGameID, + lastPlayed: input.lastPlayed, + totalPlaytime: input.totalPlaytime, + }) + .onConflictDoUpdate({ + target: [ + steamLibraryTable.ownerSteamID, + steamLibraryTable.baseGameID, + ], + set: { + timeDeleted: null, + lastPlayed: input.lastPlayed, + totalPlaytime: input.totalPlaytime, + }, + }); + }), + ); + + export const remove = fn(Info, (input) => + Database.transaction(async (tx) => + tx + .update(steamLibraryTable) + .set({ timeDeleted: sql`now()` }) + .where( + and( + eq(steamLibraryTable.ownerSteamID, input.ownerSteamID), + eq(steamLibraryTable.baseGameID, input.baseGameID), + ), + ), + ), + ); + + export const list = () => + Database.transaction(async (tx) => + tx + .select({ + games: baseGamesTable, + categories: categoriesTable, + images: imagesTable, + }) + .from(steamLibraryTable) + .where( + and( + eq(steamLibraryTable.ownerSteamID, Actor.steamID()), + isNull(steamLibraryTable.timeDeleted), + ), + ) + .innerJoin( + baseGamesTable, + eq(baseGamesTable.id, steamLibraryTable.baseGameID), + ) + .leftJoin(gamesTable, eq(gamesTable.baseGameID, baseGamesTable.id)) + .leftJoin( + categoriesTable, + and( + eq(categoriesTable.slug, gamesTable.categorySlug), + eq(categoriesTable.type, gamesTable.categoryType), + ), + ) + // 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. + // One option is to aggregate the images in SQL before joining to keep exactly one row per game: + // .leftJoin( + // sql`(SELECT * FROM images WHERE base_game_id = ${gamesTable.baseGameID} AND time_deleted IS NULL ORDER BY type, position)`.as("images"), + // sql`TRUE` + // ) + .leftJoin( + imagesTable, + and( + eq(imagesTable.baseGameID, gamesTable.baseGameID), + isNull(imagesTable.timeDeleted), + ), + ) + .execute() + .then((rows) => Game.serialize(rows)), + ); +} diff --git a/cloud/packages/core/src/steam/index.ts b/cloud/packages/core/src/steam/index.ts index 5ee9290d..5d82f2b5 100644 --- a/cloud/packages/core/src/steam/index.ts +++ b/cloud/packages/core/src/steam/index.ts @@ -4,235 +4,232 @@ import { Resource } from "sst"; import { Actor } from "../actor"; import { bus } from "sst/aws/bus"; import { Common } from "../common"; +import { Database } from "../drizzle"; import { Examples } from "../examples"; import { createEvent } from "../event"; import { eq, and, isNull, desc } from "drizzle-orm"; import { steamTable, StatusEnum, Limitations } from "./steam.sql"; -import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; export namespace Steam { - export const Info = z - .object({ - id: z.string().openapi({ - description: Common.IdDescription, - example: Examples.SteamAccount.id - }), - avatarHash: z.string().openapi({ - description: "The Steam avatar hash that this account owns", - example: Examples.SteamAccount.avatarHash - }), - status: z.enum(StatusEnum.enumValues).openapi({ - description: "The current connection status of this Steam account", - example: Examples.SteamAccount.status - }), - userID: z.string().nullable().openapi({ - description: "The user id of which account owns this steam account", - example: Examples.SteamAccount.userID - }), - profileUrl: z.string().nullable().openapi({ - description: "The steam community url of this account", - example: Examples.SteamAccount.profileUrl - }), - realName: z.string().nullable().openapi({ - description: "The real name behind of this Steam account", - example: Examples.SteamAccount.realName - }), - name: z.string().openapi({ - description: "The name used by this account", - example: Examples.SteamAccount.name - }), - lastSyncedAt: z.date().openapi({ - description: "The last time this account was synced to Steam", - example: Examples.SteamAccount.lastSyncedAt - }), - limitations: Limitations.openapi({ - description: "The limitations bestowed on this Steam account by Steam", - example: Examples.SteamAccount.limitations - }), - steamMemberSince: z.date().openapi({ - description: "When this Steam community account was created", - example: Examples.SteamAccount.steamMemberSince - }) - }) - .openapi({ - ref: "Steam", - description: "Represents a steam user's information stored on Nestri", - example: Examples.SteamAccount, + export const Info = z + .object({ + id: z.string().openapi({ + description: Common.IdDescription, + example: Examples.SteamAccount.id, + }), + avatarHash: z.string().openapi({ + description: "The Steam avatar hash that this account owns", + example: Examples.SteamAccount.avatarHash, + }), + status: z.enum(StatusEnum.enumValues).openapi({ + description: "The current connection status of this Steam account", + example: Examples.SteamAccount.status, + }), + userID: z.string().nullable().openapi({ + description: "The user id of which account owns this steam account", + example: Examples.SteamAccount.userID, + }), + profileUrl: z.string().nullable().openapi({ + description: "The steam community url of this account", + example: Examples.SteamAccount.profileUrl, + }), + realName: z.string().nullable().openapi({ + description: "The real name behind of this Steam account", + example: Examples.SteamAccount.realName, + }), + name: z.string().openapi({ + description: "The name used by this account", + example: Examples.SteamAccount.name, + }), + lastSyncedAt: z.date().openapi({ + description: "The last time this account was synced to Steam", + example: Examples.SteamAccount.lastSyncedAt, + }), + limitations: Limitations.openapi({ + description: "The limitations bestowed on this Steam account by Steam", + example: Examples.SteamAccount.limitations, + }), + steamMemberSince: z.date().openapi({ + description: "When this Steam community account was created", + example: Examples.SteamAccount.steamMemberSince, + }), + }) + .openapi({ + ref: "Steam", + description: "Represents a steam user's information stored on Nestri", + example: Examples.SteamAccount, + }); + + export type Info = z.infer; + + export const Events = { + Created: createEvent( + "steam_account.created", + z.object({ + steamID: Info.shape.id, + userID: Info.shape.userID, + }), + ), + Updated: createEvent( + "steam_account.updated", + z.object({ + steamID: Info.shape.id, + userID: Info.shape.userID, + }), + ), + }; + + export const create = fn( + Info.extend({ + useUser: z.boolean(), + }).partial({ + userID: true, + status: true, + useUser: true, + lastSyncedAt: true, + }), + (input) => + Database.transaction(async (tx) => { + const accounts = await tx + .select() + .from(steamTable) + .where( + and(isNull(steamTable.timeDeleted), eq(steamTable.id, input.id)), + ) + .execute() + .then((rows) => rows.map(serialize)); + + // Update instead of create + if (accounts.length > 0) return null; + + const userID = + typeof input.userID === "string" + ? input.userID + : input.useUser + ? Actor.userID() + : null; + await tx.insert(steamTable).values({ + userID, + id: input.id, + name: input.name, + realName: input.realName, + profileUrl: input.profileUrl, + avatarHash: input.avatarHash, + limitations: input.limitations, + status: input.status ?? "offline", + steamMemberSince: input.steamMemberSince, + lastSyncedAt: input.lastSyncedAt ?? Common.utc(), }); - export type Info = z.infer; + // await afterTx(async () => + // bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id }) + // ); - export const Events = { - Created: createEvent( - "steam_account.created", - z.object({ - steamID: Info.shape.id, - userID: Info.shape.userID, - }), - ), - Updated: createEvent( - "steam_account.updated", - z.object({ - steamID: Info.shape.id, - userID: Info.shape.userID - }), + return input.id; + }), + ); + + export const updateOwner = fn( + z + .object({ + userID: z.string(), + steamID: z.string(), + }) + .partial({ + userID: true, + }), + async (input) => + Database.transaction(async (tx) => { + const userID = input.userID ?? Actor.userID(); + await tx + .update(steamTable) + .set({ + userID, + }) + .where(eq(steamTable.id, input.steamID)); + + // await afterTx(async () => + // bus.publish(Resource.Bus, Events.Updated, { userID, steamID: input.steamID }) + // ); + + return input.steamID; + }), + ); + + export const fromUserID = fn(z.string().min(1), (userID) => + Database.transaction((tx) => + tx + .select() + .from(steamTable) + .where( + and(eq(steamTable.userID, userID), isNull(steamTable.timeDeleted)), ) - }; + .orderBy(desc(steamTable.timeCreated)) + .execute() + .then((rows) => rows.map(serialize)), + ), + ); - export const create = fn( - Info - .extend({ - useUser: z.boolean(), - }) - .partial({ - userID: true, - status: true, - useUser: true, - lastSyncedAt: true - }), - (input) => - createTransaction(async (tx) => { - const accounts = - await tx - .select() - .from(steamTable) - .where( - and( - isNull(steamTable.timeDeleted), - eq(steamTable.id, input.id) - ) - ) - .execute() - .then((rows) => rows.map(serialize)) + export const confirmOwnerShip = fn(z.string().min(1), (userID) => + Database.transaction((tx) => + tx + .select() + .from(steamTable) + .where( + and( + eq(steamTable.userID, userID), + eq(steamTable.id, Actor.steamID()), + isNull(steamTable.timeDeleted), + ), + ) + .orderBy(desc(steamTable.timeCreated)) + .execute() + .then((rows) => rows.map(serialize).at(0)), + ), + ); - // Update instead of create - if (accounts.length > 0) return null + export const fromSteamID = fn(z.string(), (steamID) => + Database.transaction((tx) => + tx + .select() + .from(steamTable) + .where(and(eq(steamTable.id, steamID), isNull(steamTable.timeDeleted))) + .orderBy(desc(steamTable.timeCreated)) + .execute() + .then((rows) => rows.map(serialize).at(0)), + ), + ); - const userID = typeof input.userID === "string" ? input.userID : input.useUser ? Actor.userID() : null; - await tx - .insert(steamTable) - .values({ - userID, - id: input.id, - name: input.name, - realName: input.realName, - profileUrl: input.profileUrl, - avatarHash: input.avatarHash, - limitations: input.limitations, - status: input.status ?? "offline", - steamMemberSince: input.steamMemberSince, - lastSyncedAt: input.lastSyncedAt ?? Common.utc(), - }) - - await afterTx(async () => - bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id }) - ); - - return input.id - }), + export const list = () => + Database.transaction((tx) => + tx + .select() + .from(steamTable) + .where( + and( + eq(steamTable.userID, Actor.userID()), + isNull(steamTable.timeDeleted), + ), + ) + .orderBy(desc(steamTable.timeCreated)) + .execute() + .then((rows) => rows.map(serialize)), ); - export const updateOwner = fn( - z - .object({ - userID: z.string(), - steamID: z.string() - }) - .partial({ - userID: true - }), - async (input) => - createTransaction(async (tx) => { - const userID = input.userID ?? Actor.userID() - await tx - .update(steamTable) - .set({ - userID - }) - .where(eq(steamTable.id, input.steamID)); - - await afterTx(async () => - bus.publish(Resource.Bus, Events.Updated, { userID, steamID: input.steamID }) - ); - - return input.steamID - }) - ) - - export const fromUserID = fn( - z.string().min(1), - (userID) => - useTransaction((tx) => - tx - .select() - .from(steamTable) - .where(and(eq(steamTable.userID, userID), isNull(steamTable.timeDeleted))) - .orderBy(desc(steamTable.timeCreated)) - .execute() - .then((rows) => rows.map(serialize)) - ) - ) - - export const confirmOwnerShip = fn( - z.string().min(1), - (userID) => - useTransaction((tx) => - tx - .select() - .from(steamTable) - .where( - and( - eq(steamTable.userID, userID), - eq(steamTable.id, Actor.steamID()), - isNull(steamTable.timeDeleted) - ) - ) - .orderBy(desc(steamTable.timeCreated)) - .execute() - .then((rows) => rows.map(serialize).at(0)) - ) - ) - - export const fromSteamID = fn( - z.string(), - (steamID) => - useTransaction((tx) => - tx - .select() - .from(steamTable) - .where(and(eq(steamTable.id, steamID), isNull(steamTable.timeDeleted))) - .orderBy(desc(steamTable.timeCreated)) - .execute() - .then((rows) => rows.map(serialize).at(0)) - ) - ) - - export const list = () => - useTransaction((tx) => - tx - .select() - .from(steamTable) - .where(and(eq(steamTable.userID, Actor.userID()), isNull(steamTable.timeDeleted))) - .orderBy(desc(steamTable.timeCreated)) - .execute() - .then((rows) => rows.map(serialize)) - ) - - export function serialize( - input: typeof steamTable.$inferSelect, - ): z.infer { - return { - id: input.id, - name: input.name, - status: input.status, - userID: input.userID, - realName: input.realName, - profileUrl: input.profileUrl, - avatarHash: input.avatarHash, - limitations: input.limitations, - lastSyncedAt: input.lastSyncedAt, - steamMemberSince: input.steamMemberSince, - }; - } - -} \ No newline at end of file + export function serialize( + input: typeof steamTable.$inferSelect, + ): z.infer { + return { + id: input.id, + name: input.name, + status: input.status, + userID: input.userID, + realName: input.realName, + profileUrl: input.profileUrl, + avatarHash: input.avatarHash, + limitations: input.limitations, + lastSyncedAt: input.lastSyncedAt, + steamMemberSince: input.steamMemberSince, + }; + } +}