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 { 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<typeof Info>;
export type Info = z.infer<typeof Info>;
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<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 { 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<typeof Info>;
}).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<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)
.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<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: []
})
}
}
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 { 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<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({
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<typeof Info>;
export type InputInfo = z.infer<typeof InputInfo>;
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<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
}))
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();
}
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 { 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<typeof Info>;
export type Info = z.infer<typeof Info>;
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<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
}
})
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));
}
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 { 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<typeof Info>;
export type Info = z.infer<typeof Info>
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<typeof Info> {
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: [],
})
}
}
export function serialize(
input: (typeof imagesTable.$inferSelect)[],
): z.infer<typeof Info> {
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: [],
},
);
}
}

View File

@@ -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<typeof Info>;
export type Info = z.infer<typeof Info>;
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<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))
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));
}
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 { 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<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 = {
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<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,
};
}
}
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,
};
}
}