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,158 +5,165 @@ 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
id: z.string().openapi({ .object({
description: Common.IdDescription, id: z.string().openapi({
example: Examples.BaseGame.id 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", slug: z.string().openapi({
example: Examples.BaseGame.slug description:
}), "A URL-friendly unique identifier for the game, used in web addresses and API endpoints",
name: z.string().openapi({ example: Examples.BaseGame.slug,
description: "The official title of the game as listed on Steam", }),
example: Examples.BaseGame.name name: z.string().openapi({
}), description: "The official title of the game as listed on Steam",
size: Size.openapi({ example: Examples.BaseGame.name,
description: "Storage requirements in bytes: downloadSize represents the compressed download, and sizeOnDisk represents the installed size", }),
example: Examples.BaseGame.size size: Size.openapi({
}), description:
releaseDate: z.date().openapi({ "Storage requirements in bytes: downloadSize represents the compressed download, and sizeOnDisk represents the installed size",
description: "The initial public release date of the game on Steam", example: Examples.BaseGame.size,
example: Examples.BaseGame.releaseDate }),
}), releaseDate: z.date().openapi({
description: z.string().nullable().openapi({ description: "The initial public release date of the game on Steam",
description: "A comprehensive overview of the game, including its features, storyline, and gameplay elements", example: Examples.BaseGame.releaseDate,
example: Examples.BaseGame.description }),
}), description: z.string().nullable().openapi({
score: z.number().openapi({ description:
description: "The aggregate user review score on Steam, represented as a percentage of positive reviews", "A comprehensive overview of the game, including its features, storyline, and gameplay elements",
example: Examples.BaseGame.score example: Examples.BaseGame.description,
}), }),
links: Links score: z.number().openapi({
.nullable() description:
.openapi({ "The aggregate user review score on Steam, represented as a percentage of positive reviews",
description: "The social links of this game", example: Examples.BaseGame.score,
example: Examples.BaseGame.links }),
}), links: Links.nullable().openapi({
primaryGenre: z.string().nullable().openapi({ description: "The social links of this game",
description: "The main category or genre that best represents the game's content and gameplay style", example: Examples.BaseGame.links,
example: Examples.BaseGame.primaryGenre }),
}), primaryGenre: z.string().nullable().openapi({
controllerSupport: z.enum(ControllerEnum.enumValues).openapi({ description:
description: "Indicates the level of gamepad/controller compatibility: 'Full', 'Partial', or 'Unkown' for no support", "The main category or genre that best represents the game's content and gameplay style",
example: Examples.BaseGame.controllerSupport example: Examples.BaseGame.primaryGenre,
}), }),
compatibility: z.enum(CompatibilityEnum.enumValues).openapi({ controllerSupport: z.enum(ControllerEnum.enumValues).openapi({
description: "Steam Deck/Proton compatibility rating indicating how well the game runs on Linux systems", description:
example: Examples.BaseGame.compatibility "Indicates the level of gamepad/controller compatibility: 'Full', 'Partial', or 'Unkown' for no support",
}), example: Examples.BaseGame.controllerSupport,
}).openapi({ }),
ref: "BaseGame", compatibility: z.enum(CompatibilityEnum.enumValues).openapi({
description: "Detailed information about a game available in the Nestri library, including technical specifications and metadata", description:
example: Examples.BaseGame "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<typeof Info>; export type Info = z.infer<typeof Info>;
export const Events = { export const Events = {
New: createEvent( New: createEvent(
"new_image.save", "new_image.save",
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(
"new_box_art_image.save", "new_box_art_image.save",
z.object({ z.object({
appID: Info.shape.id, appID: Info.shape.id,
logoUrl: z.string().url(), logoUrl: z.string().url(),
backgroundUrl: z.string().url(), backgroundUrl: z.string().url(),
}), }),
), ),
NewHeroArt: createEvent( NewHeroArt: createEvent(
"new_hero_art_image.save", "new_hero_art_image.save",
z.object({ z.object({
appID: Info.shape.id, appID: Info.shape.id,
backdropUrl: z.string().url(), backdropUrl: z.string().url(),
screenshots: z.string().url().array(), 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<typeof Info> {
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<typeof Info> {
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,
};
}
}

View File

@@ -1,128 +1,140 @@
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({
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({ export const Info = z
slug: z.string().openapi({ .object({
description: "A URL-friendly unique identifier for the category", publishers: Category.array().openapi({
example: "action-adventure" description:
}), "List of companies or organizations responsible for publishing and distributing the game",
name: z.string().openapi({ example: Examples.Categories.publishers,
description: "The human-readable display name of the category", }),
example: "Action Adventure" 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 = export type Info = z.infer<typeof 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({ export const InputInfo = createSelectSchema(categoriesTable).omit({
ref: "Categories", timeCreated: true,
description: "A comprehensive categorization system for games, including publishing details, development credits, and content classification", timeDeleted: true,
example: Examples.Categories timeUpdated: true,
}) });
export type Info = z.infer<typeof Info>; 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) if (result) return result.slug;
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
export const create = fn( await tx
InputInfo, .insert(categoriesTable)
(input) => .values(input)
createTransaction(async (tx) => { .onConflictDoUpdate({
const result = await tx target: [categoriesTable.slug, categoriesTable.type],
.select() set: { timeDeleted: null },
.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))
if (result) return result.slug return input.slug;
}),
);
await tx export const get = fn(InputInfo.pick({ slug: true, type: true }), (input) =>
.insert(categoriesTable) Database.transaction((tx) =>
.values(input) tx
.onConflictDoUpdate({ .select()
target: [categoriesTable.slug, categoriesTable.type], .from(categoriesTable)
set: { timeDeleted: null } .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 function serialize(
}) input: (typeof categoriesTable.$inferSelect)[],
) ): z.infer<typeof Info> {
return input.reduce<
export const get = fn( Record<
InputInfo.pick({ slug: true, type: true }), `${(typeof categoriesTable.$inferSelect)["type"]}s`,
(input) => { slug: string; name: string }[]
useTransaction((tx) => >
tx >(
.select() (acc, cat) => {
.from(categoriesTable) const key = `${cat.type}s` as `${typeof cat.type}s`;
.where( acc[key]!.push({ slug: cat.slug, name: cat.name });
and( return acc;
eq(categoriesTable.slug, input.slug), },
eq(categoriesTable.type, input.type), {
isNull(categoriesTable.timeDeleted) tags: [],
) genres: [],
) publishers: [],
.limit(1) developers: [],
.execute() categories: [],
.then(rows => serialize(rows)) franchises: [],
) },
) );
}
export function serialize( }
input: typeof categoriesTable.$inferSelect[],
): z.infer<typeof Info> {
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: []
})
}
}

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,180 +12,161 @@ 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({
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<typeof Info>;
export type InputInfo = z.infer<typeof InputInfo>;
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({ .onConflictDoUpdate({
ref: "Friend", target: [friendTable.steamID, friendTable.friendSteamID],
description: "Represents a friend's information stored on Nestri", set: { timeDeleted: null },
example: Examples.Friend,
}); });
return steamID;
}),
);
export const InputInfo = createSelectSchema(friendTable) export const end = fn(InputInfo, (input) =>
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true }) 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<typeof Info>; export const list = () =>
export type InputInfo = z.infer<typeof InputInfo>; Database.transaction(async (tx) =>
tx
export const add = fn( .select({
InputInfo.partial({ steamID: true }), steam: steamTable,
async (input) => user: userTable,
createTransaction(async (tx) => { })
const steamID = input.steamID ?? Actor.steamID() .from(friendTable)
if (steamID === input.friendSteamID) { .innerJoin(steamTable, eq(friendTable.friendSteamID, steamTable.id))
throw new VisibleError( .leftJoin(userTable, eq(steamTable.userID, userTable.id))
"forbidden", .where(
ErrorCodes.Validation.INVALID_PARAMETER, and(
"Cannot add yourself as a friend" eq(friendTable.steamID, Actor.steamID()),
); isNull(friendTable.timeDeleted),
} ),
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))
) )
.limit(100)
.execute()
.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) .where(
) and(
.leftJoin( eq(friendTable.steamID, Actor.steamID()),
userTable, eq(friendTable.friendSteamID, friendSteamID),
eq(steamTable.userID, userTable.id) isNull(friendTable.timeDeleted),
) ),
.where( )
and( .limit(1)
eq(friendTable.steamID, Actor.steamID()), .execute()
eq(friendTable.friendSteamID, friendSteamID), .then((rows) => serialize(rows).at(0)),
isNull(friendTable.timeDeleted) ),
) );
)
.limit(1)
.execute()
.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, const result = await tx
(friendSteamID) => .select()
useTransaction(async (tx) => { .from(friendTable)
const result = await tx .where(
.select() and(
.from(friendTable) eq(friendTable.steamID, Actor.steamID()),
.where( eq(friendTable.friendSteamID, friendSteamID),
and( isNull(friendTable.timeDeleted),
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<typeof Info>[] {
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
}))
) )
} .limit(1)
.execute();
} return result.length > 0;
}),
);
export function serialize(
input: {
user: typeof userTable.$inferSelect | null;
steam: typeof steamTable.$inferSelect;
}[],
): z.infer<typeof Info>[] {
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,
})),
);
}
}

View File

@@ -11,119 +11,131 @@ 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) => { .select()
const result = .from(gamesTable)
await tx .where(
.select() and(
.from(gamesTable) eq(gamesTable.categorySlug, input.categorySlug),
.where( eq(gamesTable.categoryType, input.categoryType),
and( eq(gamesTable.baseGameID, input.baseGameID),
eq(gamesTable.categorySlug, input.categorySlug), isNull(gamesTable.timeDeleted),
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<typeof Info>[] {
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
}
})
) )
} .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) =>
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<typeof Info>[] {
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,
};
}),
);
}
}

View File

@@ -1,119 +1,151 @@
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({ }),
description: "The calculated dominant color of the image with light/dark classification, used for UI theming", averageColor: ImageColor.openapi({
example: Examples.CommonImg[0].averageColor description:
}), "The calculated dominant color of the image with light/dark classification, used for UI theming",
dimensions: ImageDimensions.openapi({ example: Examples.CommonImg[0].averageColor,
description: "The width and height dimensions of the image in pixels", }),
example: Examples.CommonImg[0].dimensions dimensions: ImageDimensions.openapi({
}), description: "The width and height dimensions of the image in pixels",
fileSize: z.number().int().openapi({ example: Examples.CommonImg[0].dimensions,
description: "The size of the image file in bytes, used for storage and bandwidth calculations", }),
example: Examples.CommonImg[0].fileSize 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({ export type Info = z.infer<typeof Info>;
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<typeof Info> export const InputInfo = createSelectSchema(imagesTable).omit({
timeCreated: true,
timeDeleted: true,
timeUpdated: true,
});
export const InputInfo = createSelectSchema(imagesTable) export const create = fn(InputInfo, (input) =>
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true }) 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( export function serialize(
InputInfo, input: (typeof imagesTable.$inferSelect)[],
(input) => ): z.infer<typeof Info> {
createTransaction(async (tx) => return input
tx .sort((a, b) => {
.insert(imagesTable) if (a.type === b.type) {
.values(input) return a.position - b.position;
.onConflictDoUpdate({ }
target: [imagesTable.imageHash, imagesTable.type, imagesTable.baseGameID, imagesTable.position], return a.type.localeCompare(b.type);
set: { timeDeleted: null } })
}) .reduce<
) Record<
) `${(typeof imagesTable.$inferSelect)["type"]}s`,
{
export function serialize( hash: string;
input: typeof imagesTable.$inferSelect[], averageColor: ImageColor;
): z.infer<typeof Info> { dimensions: ImageDimensions;
return input fileSize: number;
.sort((a, b) => { }[]
if (a.type === b.type) { >
return a.position - b.position; >(
} (acc, img) => {
return a.type.localeCompare(b.type); const key = `${img.type}s` as `${typeof img.type}s`;
}) if (Array.isArray(acc[key])) {
.reduce<Record<`${typeof imagesTable.$inferSelect["type"]}s`, { hash: string; averageColor: ImageColor; dimensions: ImageDimensions; fileSize: number }[]>>((acc, img) => { acc[key]!.push({
const key = `${img.type}s` as `${typeof img.type}s` hash: img.imageHash,
if (Array.isArray(acc[key])) { averageColor: img.extractedColor,
acc[key]!.push({ dimensions: img.dimensions,
hash: img.imageHash, fileSize: img.fileSize,
averageColor: img.extractedColor, });
dimensions: img.dimensions, }
fileSize: img.fileSize return acc;
}) },
} {
return acc screenshots: [],
}, { boxArts: [],
screenshots: [], banners: [],
boxArts: [], heroArts: [],
banners: [], posters: [],
heroArts: [], backdrops: [],
posters: [], icons: [],
backdrops: [], logos: [],
icons: [], },
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,129 +11,124 @@ 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>;
export const Events = { export const Events = {
Add: createEvent( Add: createEvent(
"library.add", "library.add",
z.object({ z.object({
appID: z.number(), appID: z.number(),
lastPlayed: z.date().nullable(), lastPlayed: z.date().nullable(),
totalPlaytime: z.number(), totalPlaytime: z.number(),
}), }),
), ),
}; };
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() .select()
const result = .from(steamLibraryTable)
await tx .where(
.select() and(
.from(steamLibraryTable) eq(steamLibraryTable.baseGameID, input.baseGameID),
.where( eq(steamLibraryTable.ownerSteamID, ownerSteamID),
and( isNull(steamLibraryTable.timeDeleted),
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<typeof imagesTable.$inferSelect[]>`(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))
) )
.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) =>
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<typeof imagesTable.$inferSelect[]>`(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)),
);
}

View File

@@ -4,235 +4,232 @@ 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",
description: "Represents a steam user's information stored on Nestri", description: "Represents a steam user's information stored on Nestri",
example: Examples.SteamAccount, example: Examples.SteamAccount,
});
export type Info = z.infer<typeof Info>;
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<typeof Info>; // await afterTx(async () =>
// bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id })
// );
export const Events = { return input.id;
Created: createEvent( }),
"steam_account.created", );
z.object({
steamID: Info.shape.id, export const updateOwner = fn(
userID: Info.shape.userID, z
}), .object({
), userID: z.string(),
Updated: createEvent( steamID: z.string(),
"steam_account.updated", })
z.object({ .partial({
steamID: Info.shape.id, userID: true,
userID: Info.shape.userID }),
}), 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( export const confirmOwnerShip = fn(z.string().min(1), (userID) =>
Info Database.transaction((tx) =>
.extend({ tx
useUser: z.boolean(), .select()
}) .from(steamTable)
.partial({ .where(
userID: true, and(
status: true, eq(steamTable.userID, userID),
useUser: true, eq(steamTable.id, Actor.steamID()),
lastSyncedAt: true isNull(steamTable.timeDeleted),
}), ),
(input) => )
createTransaction(async (tx) => { .orderBy(desc(steamTable.timeCreated))
const accounts = .execute()
await tx .then((rows) => rows.map(serialize).at(0)),
.select() ),
.from(steamTable) );
.where(
and(
isNull(steamTable.timeDeleted),
eq(steamTable.id, input.id)
)
)
.execute()
.then((rows) => rows.map(serialize))
// Update instead of create export const fromSteamID = fn(z.string(), (steamID) =>
if (accounts.length > 0) return null 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; export const list = () =>
await tx Database.transaction((tx) =>
.insert(steamTable) tx
.values({ .select()
userID, .from(steamTable)
id: input.id, .where(
name: input.name, and(
realName: input.realName, eq(steamTable.userID, Actor.userID()),
profileUrl: input.profileUrl, isNull(steamTable.timeDeleted),
avatarHash: input.avatarHash, ),
limitations: input.limitations, )
status: input.status ?? "offline", .orderBy(desc(steamTable.timeCreated))
steamMemberSince: input.steamMemberSince, .execute()
lastSyncedAt: input.lastSyncedAt ?? Common.utc(), .then((rows) => rows.map(serialize)),
})
await afterTx(async () =>
bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id })
);
return input.id
}),
); );
export const updateOwner = fn( export function serialize(
z input: typeof steamTable.$inferSelect,
.object({ ): z.infer<typeof Info> {
userID: z.string(), return {
steamID: z.string() id: input.id,
}) name: input.name,
.partial({ status: input.status,
userID: true userID: input.userID,
}), realName: input.realName,
async (input) => profileUrl: input.profileUrl,
createTransaction(async (tx) => { avatarHash: input.avatarHash,
const userID = input.userID ?? Actor.userID() limitations: input.limitations,
await tx lastSyncedAt: input.lastSyncedAt,
.update(steamTable) steamMemberSince: input.steamMemberSince,
.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<typeof Info> {
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,
};
}
}