feat: Implement Game Image Support with Metadata & Schema Updates (#277)

## Description
<!-- Briefly describe the purpose and scope of your changes -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced support for associating rich image metadata (color,
dimensions, file size) with games, organized by categories like
screenshots, box art, posters, hero art, backgrounds, logos, and icons.
- Game and library listings now include related image collections for
enhanced browsing and detail views.

- **Improvements**
- Updated game library management to use a consistent base game
identifier, improving data consistency and reliability.
- Enhanced data schemas and access permissions to allow public viewing
of game images and refined access control for game libraries.
- Added comprehensive database schema updates for games, categories,
images, and libraries to support new features and ensure data integrity.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wanjohi
2025-05-10 22:47:28 +03:00
committed by GitHub
parent 38ad74d14a
commit 5806dc6e86
13 changed files with 2933 additions and 54 deletions

View File

@@ -0,0 +1,89 @@
CREATE TYPE "public"."compatibility" AS ENUM('high', 'mid', 'low', 'unknown');--> statement-breakpoint
CREATE TYPE "public"."category_type" AS ENUM('tag', 'genre', 'publisher', 'developer');--> statement-breakpoint
CREATE TYPE "public"."image_type" AS ENUM('heroArt', 'icon', 'logo', 'superHeroArt', 'poster', 'boxArt', 'screenshot', 'background');--> statement-breakpoint
CREATE TABLE "base_games" (
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"id" varchar(255) PRIMARY KEY NOT NULL,
"slug" varchar(255) NOT NULL,
"name" text NOT NULL,
"release_date" timestamp with time zone NOT NULL,
"size" json NOT NULL,
"description" text NOT NULL,
"primary_genre" text NOT NULL,
"controller_support" text,
"compatibility" "compatibility" DEFAULT 'unknown' NOT NULL,
"score" numeric(2, 1) NOT NULL,
CONSTRAINT "idx_base_games_slug" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "categories" (
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"slug" varchar(255) NOT NULL,
"type" "category_type" NOT NULL,
"name" text NOT NULL,
CONSTRAINT "categories_slug_type_pk" PRIMARY KEY("slug","type")
);
--> statement-breakpoint
CREATE TABLE "games" (
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"base_game_id" varchar(255) NOT NULL,
"category_slug" varchar(255) NOT NULL,
"type" "category_type" NOT NULL,
CONSTRAINT "games_base_game_id_category_slug_type_pk" PRIMARY KEY("base_game_id","category_slug","type")
);
--> statement-breakpoint
CREATE TABLE "images" (
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"type" "image_type" NOT NULL,
"image_hash" varchar(255) NOT NULL,
"base_game_id" varchar(255) NOT NULL,
"source_url" text NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"file_size" integer NOT NULL,
"dimensions" json NOT NULL,
"extracted_color" json NOT NULL,
CONSTRAINT "images_image_hash_type_base_game_id_position_pk" PRIMARY KEY("image_hash","type","base_game_id","position")
);
--> statement-breakpoint
CREATE TABLE "game_libraries" (
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"base_game_id" varchar(255) NOT NULL,
"owner_id" varchar(255) NOT NULL,
CONSTRAINT "game_libraries_base_game_id_owner_id_pk" PRIMARY KEY("base_game_id","owner_id")
);
--> statement-breakpoint
ALTER TABLE "steam_accounts" RENAME COLUMN "steam_id" TO "id";--> statement-breakpoint
ALTER TABLE "steam_account_credentials" DROP CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_steam_id_fk";
--> statement-breakpoint
ALTER TABLE "friends_list" DROP CONSTRAINT "friends_list_steam_id_steam_accounts_steam_id_fk";
--> statement-breakpoint
ALTER TABLE "friends_list" DROP CONSTRAINT "friends_list_friend_steam_id_steam_accounts_steam_id_fk";
--> statement-breakpoint
ALTER TABLE "members" DROP CONSTRAINT "members_steam_id_steam_accounts_steam_id_fk";
--> statement-breakpoint
ALTER TABLE "games" ADD CONSTRAINT "games_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "games" ADD CONSTRAINT "games_categories_fkey" FOREIGN KEY ("category_slug","type") REFERENCES "public"."categories"("slug","type") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "images" ADD CONSTRAINT "images_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_owner_id_steam_accounts_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_categories_type" ON "categories" USING btree ("type");--> statement-breakpoint
CREATE INDEX "idx_games_category_slug" ON "games" USING btree ("category_slug");--> statement-breakpoint
CREATE INDEX "idx_games_category_type" ON "games" USING btree ("type");--> statement-breakpoint
CREATE INDEX "idx_images_type" ON "images" USING btree ("type");--> statement-breakpoint
CREATE INDEX "idx_images_game_id" ON "images" USING btree ("base_game_id");--> statement-breakpoint
CREATE INDEX "idx_game_libraries_owner_id" ON "game_libraries" USING btree ("owner_id");--> statement-breakpoint
ALTER TABLE "steam_account_credentials" ADD CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_friend_steam_id_steam_accounts_id_fk" FOREIGN KEY ("friend_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "members" ADD CONSTRAINT "members_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE restrict;--> statement-breakpoint
CREATE INDEX "idx_friends_list_friend_steam_id" ON "friends_list" USING btree ("friend_steam_id");

View File

@@ -0,0 +1,4 @@
ALTER TABLE "games" DROP CONSTRAINT "games_categories_fkey";
--> statement-breakpoint
ALTER TABLE "games" ADD CONSTRAINT "games_categories_fkey" FOREIGN KEY ("category_slug","type") REFERENCES "public"."categories"("slug","type") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_games_category_slug_type" ON "games" USING btree ("category_slug","type");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,20 @@
"when": 1746726715456,
"tag": "0010_certain_dust",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1746904821461,
"tag": "0011_simple_azazel",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1746905730079,
"tag": "0012_glorious_jetstream",
"breakpoints": true
}
]
}

View File

@@ -207,17 +207,60 @@ export namespace Examples {
],
}
export const Game = {
...BaseGame,
...Categories
export const CommonImg = [
{
hash: "db880dc2f0187bfe0c5d3c44a06d1002351eb3107970a83bf5667ffd3b369acd",
averageColor: {
hex: "#352c36",
isDark: true
},
dimensions: {
width: 3840,
height: 2160
},
fileSize: 976004
},
{
hash: "99f603e41dd3efde21a145fd00c9f107025c09433c084a5e5005bc2ac30e46ea",
averageColor: {
hex: "#596774",
isDark: true
},
dimensions: {
width: 2560,
height: 1440
},
fileSize: 895134
},
{
hash: "2c4193c19160392be01d08e6957ed682649117742c5abaa8c469e7408382572f",
averageColor: {
hex: "#444b5b",
isDark: true
},
dimensions: {
width: 2560,
height: 1440
},
fileSize: 738701
}
]
// type: "screenshots" as const, // or boxart(square), poster(vertical), superheroart(background), heroart(horizontal), logo, icon
export const Images = {
screenshots: CommonImg,
boxArts: CommonImg,
posters: CommonImg,
heroArts: CommonImg,
superHeroArts: CommonImg,
backgrounds: CommonImg,
logos: CommonImg,
icons: CommonImg,
}
// export const image = {
// type: "screenshot" as const, // or square, vertical, horizontal, movie
// hash: "3a5e805fd4c1e04e26a97af0b9c6fab2dee91a19",
// gameID: Game.id,
// extractedColors: [{}]
// }
export const Game = {
...BaseGame,
...Categories,
...Images
}
}

View File

@@ -1,7 +1,7 @@
import { timestamps } from "../drizzle/types";
import { baseGamesTable } from "../base-game/base-game.sql";
import { categoriesTable } from "../categories/categories.sql";
import { index, pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
import { categoriesTable, CategoryTypeEnum } from "../categories/categories.sql";
import { foreignKey, index, pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
export const gamesTable = pgTable(
'games',
@@ -13,21 +13,23 @@ export const gamesTable = pgTable(
{ onDelete: "cascade" }
),
categorySlug: varchar('category_slug', { length: 255 })
.notNull()
.references(() => categoriesTable.slug,
{ onDelete: "cascade" }
),
categoryType: varchar('category_type', { length: 255 })
.notNull()
.references(() => categoriesTable.type,
{ onDelete: "cascade" }
),
.notNull(),
categoryType: CategoryTypeEnum("type").notNull()
},
(table) => [
primaryKey({
columns: [table.baseGameID, table.categorySlug, table.categoryType]
}),
foreignKey({
name: "games_categories_fkey",
columns: [table.categorySlug, table.categoryType],
foreignColumns: [categoriesTable.slug, categoriesTable.type],
}).onDelete("cascade"),
index("idx_games_category_slug").on(table.categorySlug),
index("idx_games_category_type").on(table.categoryType),
index("idx_games_category_slug_type").on(
table.categorySlug,
table.categoryType
)
]
);

View File

@@ -1,11 +1,13 @@
import { z } from "zod";
import { fn } from "../utils";
import { Images } from "../images";
import { Examples } from "../examples";
import { BaseGame } from "../base-game";
import { gamesTable } from "./game.sql";
import { Categories } from "../categories";
import { eq, and, isNull } from "drizzle-orm";
import { createSelectSchema } from "drizzle-zod";
import { imagesTable } from "../images/images.sql";
import { groupBy, map, pipe, uniqueBy, values } from "remeda";
import { baseGamesTable } from "../base-game/base-game.sql";
import { categoriesTable } from "../categories/categories.sql";
@@ -13,7 +15,7 @@ import { createTransaction, useTransaction } from "../drizzle/transaction";
export namespace Game {
export const Info = z
.intersection(BaseGame.Info, Categories.Info)
.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",
@@ -65,6 +67,7 @@ export namespace Game {
.select({
games: baseGamesTable,
categories: categoriesTable,
images: imagesTable
})
.from(gamesTable)
.innerJoin(baseGamesTable,
@@ -76,6 +79,12 @@ export namespace Game {
eq(categoriesTable.type, gamesTable.categoryType),
)
)
.leftJoin(imagesTable,
and(
eq(imagesTable.baseGameID, gamesTable.baseGameID),
isNull(imagesTable.timeDeleted),
)
)
.where(
and(
eq(gamesTable.baseGameID, gameID),
@@ -88,7 +97,7 @@ export namespace Game {
)
export function serialize(
input: { games: typeof baseGamesTable.$inferSelect; categories: typeof categoriesTable.$inferSelect | null }[],
input: { games: typeof baseGamesTable.$inferSelect; categories: typeof categoriesTable.$inferSelect | null; images: typeof imagesTable.$inferSelect | null }[],
): z.infer<typeof Info>[] {
return pipe(
input,
@@ -100,10 +109,16 @@ export namespace Game {
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

@@ -0,0 +1,46 @@
import { timestamps } from "../drizzle/types";
import { baseGamesTable } from "../base-game/base-game.sql";
import { index, integer, json, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
import { z } from "zod";
export const ImageTypeEnum = pgEnum("image_type", ["heroArt", "icon", "logo", "superHeroArt", "poster", "boxArt", "screenshot","background"])
export const ImageDimensions = z.object({
width: z.number().int(),
height: z.number().int(),
})
export const ImageColor = z.object({
hex: z.string(),
isDark: z.boolean()
})
export type ImageColor = z.infer<typeof ImageColor>;
export type ImageDimensions = z.infer<typeof ImageDimensions>;
export const imagesTable = pgTable(
"images",
{
...timestamps,
type: ImageTypeEnum("type").notNull(),
imageHash: varchar("image_hash", { length: 255 })
.notNull(),
baseGameID: varchar("base_game_id", { length: 255 })
.notNull()
.references(() => baseGamesTable.id, {
onDelete: "cascade"
}),
sourceUrl: text("source_url").notNull(),
position: integer("position").notNull().default(0),
fileSize: integer("file_size").notNull(),
dimensions: json("dimensions").$type<ImageDimensions>().notNull(),
extractedColor: json("extracted_color").$type<ImageColor>().notNull(),
},
(table) => [
primaryKey({
columns: [table.imageHash, table.type, table.baseGameID, table.position]
}),
index("idx_images_type").on(table.type),
index("idx_images_game_id").on(table.baseGameID),
]
)

View File

@@ -0,0 +1,119 @@
import { z } from "zod";
import { fn } from "../utils";
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
})
})
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
}),
heroArts: Image.array().openapi({
description: "Horizontal promotional artwork optimized for header displays and banners",
example: Examples.Images.heroArts
}),
superHeroArts: Image.array().openapi({
description: "High-resolution, wide-format artwork designed for featured content and main entries",
example: Examples.Images.superHeroArts
}),
backgrounds: Image.array().openapi({
description: "Full-width background images optimized for page layouts and decorative purposes",
example: Examples.Images.backgrounds
}),
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 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: [],
superHeroArts: [],
heroArts: [],
posters: [],
backgrounds: [],
icons: [],
logos: [],
})
}
}

View File

@@ -1,14 +1,15 @@
import { z } from "zod";
import { fn } from "../utils";
import { Game } from "../game";
import { Actor } from "../actor";
import { gamesTable } from "../game/game.sql";
import { createSelectSchema } from "drizzle-zod";
import { steamLibraryTable } from "./library.sql";
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
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";
import { Actor } from "../actor";
export namespace Library {
export const Info = createSelectSchema(steamLibraryTable)
@@ -26,7 +27,7 @@ export namespace Library {
.from(steamLibraryTable)
.where(
and(
eq(steamLibraryTable.gameID, input.gameID),
eq(steamLibraryTable.baseGameID, input.baseGameID),
eq(steamLibraryTable.ownerID, input.ownerID),
isNull(steamLibraryTable.timeDeleted)
)
@@ -37,12 +38,9 @@ export namespace Library {
await tx
.insert(steamLibraryTable)
.values({
ownerID: input.ownerID,
gameID: input.gameID
})
.values(input)
.onConflictDoUpdate({
target: [steamLibraryTable.ownerID, steamLibraryTable.gameID],
target: [steamLibraryTable.ownerID, steamLibraryTable.baseGameID],
set: { timeDeleted: null }
})
@@ -59,7 +57,7 @@ export namespace Library {
.where(
and(
eq(steamLibraryTable.ownerID, input.ownerID),
eq(steamLibraryTable.gameID, input.gameID),
eq(steamLibraryTable.baseGameID, input.baseGameID),
)
)
)
@@ -71,6 +69,7 @@ export namespace Library {
.select({
games: baseGamesTable,
categories: categoriesTable,
images: imagesTable
})
.from(steamLibraryTable)
.where(
@@ -81,7 +80,7 @@ export namespace Library {
)
.innerJoin(
baseGamesTable,
eq(baseGamesTable.id, steamLibraryTable.gameID),
eq(baseGamesTable.id, steamLibraryTable.baseGameID),
)
.leftJoin(
gamesTable,
@@ -94,6 +93,20 @@ export namespace Library {
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

@@ -8,7 +8,7 @@ export const steamLibraryTable = pgTable(
"game_libraries",
{
...timestamps,
gameID: varchar("game_id", { length: 255 })
baseGameID: varchar("base_game_id", { length: 255 })
.notNull()
.references(() => baseGamesTable.id, {
onDelete: "cascade"
@@ -21,7 +21,7 @@ export const steamLibraryTable = pgTable(
},
(table) => [
primaryKey({
columns: [table.gameID, table.ownerID]
columns: [table.baseGameID, table.ownerID]
}),
index("idx_game_libraries_owner_id").on(table.ownerID),
],

View File

@@ -1,5 +1,6 @@
import { Size } from "@nestri/core/src/base-game/base-game.sql";
import { type Limitations } from "@nestri/core/src/steam/steam.sql";
import { ImageColor, ImageDimensions } from "@nestri/core/src/images/images.sql";
import {
json,
table,
@@ -82,9 +83,10 @@ const games = table("games")
.columns({
base_game_id: string(),
category_slug: string(),
type: enumeration<"tag" | "genre" | "publisher" | "developer">(),
...timestamps
})
.primaryKey("category_slug", "base_game_id")
.primaryKey("category_slug", "base_game_id", "type")
const base_games = table("base_games")
.columns({
@@ -96,7 +98,7 @@ const base_games = table("base_games")
description: string(),
primary_genre: string(),
controller_support: string().optional(),
compatibility: enumeration<"high" | "mid" | "low">(),
compatibility: enumeration<"high" | "mid" | "low" | "unknown">(),
score: number(),
...timestamps
})
@@ -109,17 +111,29 @@ const categories = table("categories")
name: string(),
...timestamps
})
.primaryKey("slug")
.primaryKey("slug", "type")
const game_libraries = table("game_libraries")
.columns({
game_id: string(),
owner_id: string()
})
base_game_id: string(),
owner_id: string(),
...timestamps
}).primaryKey("base_game_id", "owner_id")
const images = table("images")
.columns({
image_hash: string(),
base_game_id: string(),
type: enumeration<"heroArt" | "icon" | "logo" | "superHeroArt" | "poster" | "boxArt" | "screenshot" | "background">(),
position: number(),
dimensions: json<ImageDimensions>(),
extracted_color: json<ImageColor>(),
...timestamps
}).primaryKey("image_hash", "type", "base_game_id", "position")
// Schema and Relationships
export const schema = createSchema({
tables: [users, steam_accounts, teams, members, friends_list, categories, base_games, games, game_libraries],
tables: [users, steam_accounts, teams, members, friends_list, categories, base_games, games, game_libraries, images],
relationships: [
relationships(steam_accounts, (r) => ({
user: r.one({
@@ -215,22 +229,37 @@ export const schema = createSchema({
libraries: r.many({
sourceField: ["id"],
destSchema: game_libraries,
destField: ["game_id"]
destField: ["base_game_id"]
}),
images: r.many({
sourceField: ["id"],
destSchema: images,
destField: ["base_game_id"]
})
})),
relationships(categories, (r) => ({
games: r.many({
games_slug: r.many({
sourceField: ["slug"],
destSchema: games,
destField: ["category_slug"]
}),
games_type: r.many({
sourceField: ["type"],
destSchema: games,
destField: ["type"]
})
})),
relationships(games, (r) => ({
category: r.one({
category_slug: r.one({
sourceField: ["category_slug"],
destSchema: categories,
destField: ["slug"],
}),
category_type: r.one({
sourceField: ["type"],
destSchema: categories,
destField: ["type"],
}),
base_game: r.one({
sourceField: ["base_game_id"],
destSchema: base_games,
@@ -239,7 +268,7 @@ export const schema = createSchema({
})),
relationships(game_libraries, (r) => ({
base_game: r.one({
sourceField: ["game_id"],
sourceField: ["base_game_id"],
destSchema: base_games,
destField: ["id"],
}),
@@ -249,6 +278,13 @@ export const schema = createSchema({
destField: ["id"],
}),
})),
relationships(images, (r) => ({
base_game: r.one({
sourceField: ["base_game_id"],
destSchema: base_games,
destField: ["id"],
}),
})),
],
});
@@ -307,6 +343,17 @@ export const permissions = definePermissions<Auth, Schema>(schema, () => {
]
},
},
game_libraries: {
row: {
select: [
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.where("user_id", auth.sub)),
//allow team members to see the other members' libraries
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.related("memberEntries", (f) => f.where("user_id", auth.sub))),
//allow friends to see their friends libraries
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.related("friends", (f) => f.related("friend", (s) => s.where("user_id", auth.sub)))),
]
}
},
//Games are publicly viewable
games: {
row: {
@@ -323,14 +370,10 @@ export const permissions = definePermissions<Auth, Schema>(schema, () => {
select: ANYONE_CAN
}
},
game_libraries: {
images: {
row: {
select: [
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.where("user_id", auth.sub)),
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.related("memberEntries", (f) => f.where("user_id", auth.sub))),
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.related("friends", (f) => f.related("friend", (s) => s.where("user_id", auth.sub)))),
]
select: ANYONE_CAN
}
}
},
};
});