feat: Sync to OSS repo

This commit is contained in:
Wanjohi
2026-08-06 22:13:51 +03:00
parent 46d2a56180
commit 3faac3008f
144 changed files with 27561 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
import { sql } from 'drizzle-orm';
import { bigint, index, integer, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid } from '../db/types.js';
import { GameTable } from '../game/game.sql.js';
export const DepotStatus = pgEnum('depot_status', [
'pending',
'downloading',
'complete',
'error',
'deleted'
]);
export const GameDepotTable = pgTable(
'game_depot',
{
...id,
...timestamps,
gameId: ulid('game_id')
.notNull()
.references(() => GameTable.id, { onDelete: 'cascade' }),
depotId: integer('depot_id').notNull(),
branch: text('branch').notNull().default('public'),
steamManifestId: text('steam_manifest_id'),
steamBuildId: integer('steam_build_id'),
installedManifestId: text('installed_manifest_id'),
installedBuildId: integer('installed_build_id'),
sizeDownload: bigint('size_download', { mode: 'number' }),
sizeOnDisk: bigint('size_on_disk', { mode: 'number' }),
status: DepotStatus('status').notNull().default('pending'),
errorMessage: text('error_message'),
oslist: text('oslist')
},
(t) => [
uniqueIndex('game_depot_unique').on(t.gameId, t.depotId, t.branch),
index('game_depot_game_idx').on(t.gameId),
index('game_depot_updates_idx')
.on(t.gameId)
.where(sql`${t.installedManifestId} is distinct from ${t.steamManifestId}`)
]
);

View File

@@ -0,0 +1,266 @@
import { eq, and, isNull, sql, inArray } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { DepotStatus, GameDepotTable } from './depot.sql.js';
export namespace Depot {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the depot entry',
example: Examples.Depot.id
}),
gameId: z.string().meta({
description: 'The game this depot belongs to',
example: Examples.Depot.gameId
}),
depotId: z.number().int().meta({
description: 'Steam depot ID',
example: Examples.Depot.depotId
}),
branch: z.string().meta({
description: 'Depot branch (e.g. public)',
example: Examples.Depot.branch
}),
steamManifestId: z.string().nullable().optional().meta({
description: 'Current manifest ID from Steam',
example: Examples.Depot.steamManifestId
}),
steamBuildId: z.number().int().nullable().optional().meta({
description: 'Current build ID from Steam',
example: Examples.Depot.steamBuildId
}),
installedManifestId: z.string().nullable().optional().meta({
description: 'Installed manifest ID on this host',
example: Examples.Depot.installedManifestId
}),
installedBuildId: z.number().int().nullable().optional().meta({
description: 'Installed build ID on this host',
example: Examples.Depot.installedBuildId
}),
sizeDownload: z.number().nullable().optional().meta({
description: 'Compressed download size in bytes',
example: Examples.Depot.sizeDownload
}),
sizeOnDisk: z.number().nullable().optional().meta({
description: 'Uncompressed size in bytes',
example: Examples.Depot.sizeOnDisk
}),
status: z.enum(DepotStatus.enumValues).meta({
description: 'Current depot status',
example: Examples.Depot.status
}),
errorMessage: z.string().nullable().optional().meta({
description: 'Error message if status is error',
example: Examples.Depot.errorMessage
}),
oslist: z.string().nullable().optional().meta({
description: 'OS filter (windows, linux, mac)',
example: Examples.Depot.oslist
})
})
.meta({
ref: 'Depot',
description: 'A game depot (shared install/update tracking)',
example: Examples.Depot
});
export type Info = z.infer<typeof Info>;
export const create = fn(
Info.pick({
id: true,
gameId: true,
depotId: true,
branch: true,
steamManifestId: true,
steamBuildId: true,
installedManifestId: true,
installedBuildId: true,
sizeDownload: true,
sizeOnDisk: true,
status: true,
errorMessage: true,
oslist: true
}),
async (input) => {
await Database.use(async (tx) => {
await tx.insert(GameDepotTable).values({
id: input.id,
gameId: input.gameId,
depotId: input.depotId,
branch: input.branch ?? 'public',
steamManifestId: input.steamManifestId ?? null,
steamBuildId: input.steamBuildId ?? null,
installedManifestId: input.installedManifestId ?? null,
installedBuildId: input.installedBuildId ?? null,
sizeDownload: input.sizeDownload ?? null,
sizeOnDisk: input.sizeOnDisk ?? null,
status: input.status ?? 'pending',
errorMessage: input.errorMessage ?? null,
oslist: input.oslist ?? null
});
});
return input.id;
}
);
export const upsert = fn(
Info.pick({
id: true,
gameId: true,
depotId: true,
branch: true,
steamManifestId: true,
steamBuildId: true,
installedManifestId: true,
installedBuildId: true,
sizeDownload: true,
sizeOnDisk: true,
status: true,
errorMessage: true,
oslist: true
}),
async (input) => {
await Database.use(async (tx) => {
await tx
.insert(GameDepotTable)
.values({
id: input.id,
gameId: input.gameId,
depotId: input.depotId,
branch: input.branch ?? 'public',
steamManifestId: input.steamManifestId ?? null,
steamBuildId: input.steamBuildId ?? null,
installedManifestId: input.installedManifestId ?? null,
installedBuildId: input.installedBuildId ?? null,
sizeDownload: input.sizeDownload ?? null,
sizeOnDisk: input.sizeOnDisk ?? null,
status: input.status ?? 'pending',
errorMessage: input.errorMessage ?? null,
oslist: input.oslist ?? null
})
.onConflictDoUpdate({
target: [GameDepotTable.gameId, GameDepotTable.depotId, GameDepotTable.branch],
set: {
steamManifestId: sql`excluded.${GameDepotTable.steamManifestId.name}`,
steamBuildId: sql`excluded.${GameDepotTable.steamBuildId.name}`,
sizeDownload: sql`excluded.${GameDepotTable.sizeDownload.name}`,
sizeOnDisk: sql`excluded.${GameDepotTable.sizeOnDisk.name}`,
oslist: sql`excluded.${GameDepotTable.oslist.name}`
// Do not clobber installed_* fields — those are set by DepotJob
}
});
});
return input.id;
}
);
export const markInstalled = fn(
Info.pick({ id: true, installedManifestId: true, installedBuildId: true, status: true }),
async (input) => {
await Database.use(async (tx) => {
await tx
.update(GameDepotTable)
.set({
installedManifestId: input.installedManifestId,
installedBuildId: input.installedBuildId,
status: input.status ?? 'complete'
})
.where(eq(GameDepotTable.id, input.id));
});
}
);
export const fromID = fn(Info.shape.id, async (id) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameDepotTable)
.where(and(eq(GameDepotTable.id, id), isNull(GameDepotTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
});
export const listByGame = fn(Info.shape.gameId, async (gameId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameDepotTable)
.where(and(eq(GameDepotTable.gameId, gameId), isNull(GameDepotTable.timeDeleted)));
});
});
export const listByGameIDs = fn(z.array(z.string()), async (gameIds) => {
if (gameIds.length === 0) return [];
return Database.use(async (tx) => {
return tx
.select()
.from(GameDepotTable)
.where(and(inArray(GameDepotTable.gameId, gameIds), isNull(GameDepotTable.timeDeleted)));
});
});
export const listByGameAndDepotIDs = fn(
z.object({ gameIds: z.array(z.string()), depotIds: z.array(z.number().int()) }),
async (input) => {
if (input.gameIds.length === 0 || input.depotIds.length === 0) return [];
return Database.use(async (tx) => {
return tx
.select()
.from(GameDepotTable)
.where(
and(
inArray(GameDepotTable.gameId, input.gameIds),
inArray(GameDepotTable.depotId, input.depotIds),
isNull(GameDepotTable.timeDeleted)
)
);
});
}
);
export const getInstalled = fn(z.void(), async () => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameDepotTable)
.where(
and(
isNull(GameDepotTable.timeDeleted),
sql`${GameDepotTable.installedManifestId} IS NOT NULL`
)
);
});
});
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(GameDepotTable)
.set({ timeDeleted: sql`now()` })
.where(eq(GameDepotTable.id, id));
});
});
export function serialize(input: typeof GameDepotTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
gameId: input.gameId,
depotId: input.depotId,
branch: input.branch,
steamManifestId: input.steamManifestId,
steamBuildId: input.steamBuildId,
installedManifestId: input.installedManifestId,
installedBuildId: input.installedBuildId,
sizeDownload: input.sizeDownload,
sizeOnDisk: input.sizeOnDisk,
status: input.status as Info['status'],
errorMessage: input.errorMessage,
oslist: input.oslist
};
}
}

View File

@@ -0,0 +1,38 @@
import { bigint, index, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid, utc } from '../db/types.js';
import { GameTable } from './game.sql.js';
export const GameDownloadStatus = pgEnum('game_download_status', [
'pending',
'verifying',
'downloading',
'ready',
'failed'
]);
export const GameDownloadTable = pgTable(
'game_download',
{
...id,
...timestamps,
hostId: text('host_id').notNull(),
gameId: ulid('game_id')
.notNull()
.references(() => GameTable.id, { onDelete: 'cascade' }),
status: GameDownloadStatus('status').notNull().default('pending'),
progressBytes: bigint('progress_bytes', { mode: 'number' }).default(0),
totalBytes: bigint('total_bytes', { mode: 'number' }),
timeStarted: utc('time_started'),
timeCompleted: utc('time_completed'),
errorMessage: text('error_message')
},
(t) => [
uniqueIndex('game_download_host_game_unique').on(t.hostId, t.gameId),
index('game_download_game_idx').on(t.gameId),
index('game_download_host_status_idx').on(t.hostId, t.status)
]
);

View File

@@ -0,0 +1,230 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { testDb } from '../db/test.js';
import { Game } from '../game/index.js';
import { Identifier } from '../id.js';
import { GameDownload } from './download.js';
const sql = testDb();
const HOST_A = 'hst_aaaaaaaaaaaaaaaaaaaaaaaaa';
const HOST_B = 'hst_bbbbbbbbbbbbbbbbbbbbbbbbb';
const createdGameIds: string[] = [];
const gameIdByApp = new Map<number, string>();
async function ensureGame(steamAppId: number): Promise<string> {
const existing = gameIdByApp.get(steamAppId);
if (existing) return existing;
const [row] = await Game.upsert({
id: Identifier.ascending('game'),
steamAppId,
slug: `test-game-${steamAppId}`,
name: `Test Game ${steamAppId}`
});
if (!row) throw new Error('expected a game row');
createdGameIds.push(row.id);
gameIdByApp.set(steamAppId, row.id);
return row.id;
}
beforeAll(async () => {
await ensureGame(4400);
await ensureGame(4401);
await ensureGame(4402);
});
afterAll(async () => {
if (createdGameIds.length > 0) {
// Deleting the games cascades to their game_download rows.
await sql`delete from "game" where id in ${sql(createdGameIds)}`;
createdGameIds.length = 0;
}
});
describe('GameDownload', () => {
function expectRow<T>(row: T | null | undefined): T {
expect(row).not.toBeNull();
return row as T;
}
test('a new host/game creates one row', async () => {
const gameId = await ensureGame(4400);
const row = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'downloading',
progressBytes: 1000,
totalBytes: 5000
})
);
expect(row.id).toMatch(/^gdl_/);
expect(row.hostId).toBe(HOST_A);
expect(row.gameId).toBe(gameId);
expect(row.status).toBe('downloading');
expect(row.timeStarted).toBeInstanceOf(Date);
});
test('repeating the same host/game updates one row', async () => {
const gameId = await ensureGame(4400);
const first = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'downloading',
progressBytes: 1000,
totalBytes: 5000
})
);
const second = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'downloading',
progressBytes: 2000,
totalBytes: 5000
})
);
expect(second.id).toBe(first.id);
expect(second.progressBytes).toBe(2000);
expect(second.totalBytes).toBe(5000);
const count =
await sql`select count(*)::int as n from "game_download" where host_id = ${HOST_A} and game_id = ${gameId}`;
expect(count[0]?.n).toBe(1);
});
test('reports for other users do not create another row', async () => {
const gameId = await ensureGame(4401);
// No user dimension exists on the shared state row: reports for any
// user land on the single (host, game) row.
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'downloading',
progressBytes: 1
});
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'downloading',
progressBytes: 2
});
const rows = await GameDownload.listByHost(HOST_A);
expect(rows.filter((r) => r.gameId === gameId).length).toBe(1);
});
test('two hosts create two independent rows', async () => {
const gameId = await ensureGame(4401);
const a = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'downloading'
})
);
const b = expectRow(
await GameDownload.upsertState({
hostId: HOST_B,
gameId,
status: 'downloading'
})
);
expect(a.id).not.toBe(b.id);
expect(a.hostId).toBe(HOST_A);
expect(b.hostId).toBe(HOST_B);
});
test('verifying is accepted', async () => {
const gameId = await ensureGame(4401);
const row = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'verifying',
progressBytes: 2048
})
);
expect(row.status).toBe('verifying');
expect(row.timeStarted).toBeInstanceOf(Date);
});
test('ready sets timeCompleted', async () => {
const gameId = await ensureGame(4400);
const row = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'ready',
progressBytes: 5000,
totalBytes: 5000
})
);
expect(row.status).toBe('ready');
expect(row.timeCompleted).toBeInstanceOf(Date);
expect(row.timeStarted).toBeInstanceOf(Date);
});
test('failed records an error', async () => {
const gameId = await ensureGame(4401);
const row = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'failed',
errorMessage: 'depot key missing'
})
);
expect(row.status).toBe('failed');
expect(row.errorMessage).toBe('depot key missing');
});
test('progress updates do not require a user ID', async () => {
const gameId = await ensureGame(4402);
const row = expectRow(
await GameDownload.upsertState({
hostId: HOST_B,
gameId,
status: 'downloading',
progressBytes: 12345
})
);
expect(row.progressBytes).toBe(12345);
expect(row).not.toHaveProperty('userId');
});
test('old user_download table is no longer referenced', async () => {
const rows =
await sql`select table_name from information_schema.tables where table_schema = 'public' and table_name = 'user_download'`;
expect(rows.length).toBe(0);
});
test('findByHostAndGame and listByGame', async () => {
const gameId = await ensureGame(4402);
const found = await GameDownload.findByHostAndGame({ hostId: HOST_B, gameId });
expect(found).not.toBeNull();
expect(found!.gameId).toBe(gameId);
const byGame = await GameDownload.listByGame(gameId);
expect(byGame.some((r) => r.hostId === HOST_B)).toBe(true);
});
test('markReady and markFailed update the row', async () => {
const gameId = await ensureGame(4402);
const ready = await GameDownload.markReady({ hostId: HOST_B, gameId });
expect(ready!.status).toBe('ready');
expect(ready!.timeCompleted).toBeInstanceOf(Date);
const failed = await GameDownload.markFailed({
hostId: HOST_B,
gameId,
errorMessage: 'disk full'
});
expect(failed!.status).toBe('failed');
expect(failed!.errorMessage).toBe('disk full');
});
});

View File

@@ -0,0 +1,207 @@
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { Identifier } from '../id.js';
import { GameDownloadStatus, GameDownloadTable } from './download.sql.js';
export namespace GameDownload {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the download state record',
example: Examples.GameDownload.id
}),
hostId: z.string().meta({
description: 'The nessh host performing the download',
example: Examples.GameDownload.hostId
}),
gameId: z.string().meta({
description: 'The game being downloaded',
example: Examples.GameDownload.gameId
}),
status: z.enum(GameDownloadStatus.enumValues).meta({
description: 'Current download status',
example: Examples.GameDownload.status
}),
progressBytes: z.number().nullable().optional().meta({
description: 'Bytes downloaded so far',
example: Examples.GameDownload.progressBytes
}),
totalBytes: z.number().nullable().optional().meta({
description: 'Total bytes to download',
example: Examples.GameDownload.totalBytes
}),
timeStarted: z.string().nullable().optional().meta({
description: 'When the download started (ISO 8601)',
example: Examples.GameDownload.timeStarted
}),
timeCompleted: z.string().nullable().optional().meta({
description: 'When the download completed (ISO 8601)',
example: Examples.GameDownload.timeCompleted
}),
errorMessage: z.string().nullable().optional().meta({
description: 'Error message if status is failed',
example: Examples.GameDownload.errorMessage
})
})
.meta({
ref: 'GameDownload',
description: 'Per-host game download state, shared across users',
example: Examples.GameDownload
});
export type Info = z.infer<typeof Info>;
/**
* Atomically insert or update the state row for a (host, game) pair and
* return the actual database row. Timestamps are derived from status:
* `downloading`/`verifying` set `timeStarted` (preserving an existing
* start on resume), `ready` sets `timeCompleted`.
*/
export const upsertState = fn(
Info.pick({
hostId: true,
gameId: true,
status: true,
progressBytes: true,
totalBytes: true,
errorMessage: true
}),
async (input) => {
return Database.use(async (tx) => {
const started = input.status === 'downloading' || input.status === 'verifying';
const [row] = await tx
.insert(GameDownloadTable)
.values({
id: Identifier.ascending('gameDownload'),
hostId: input.hostId,
gameId: input.gameId,
status: input.status,
progressBytes: input.progressBytes ?? null,
totalBytes: input.totalBytes ?? null,
errorMessage: input.status === 'failed' ? (input.errorMessage ?? null) : null,
timeStarted: started ? new Date() : null,
timeCompleted: input.status === 'ready' ? new Date() : null
})
.onConflictDoUpdate({
target: [GameDownloadTable.hostId, GameDownloadTable.gameId],
set: {
status: sql`excluded.${sql.identifier(GameDownloadTable.status.name)}`,
progressBytes: sql`coalesce(excluded.${sql.identifier(GameDownloadTable.progressBytes.name)}, ${GameDownloadTable.progressBytes})`,
totalBytes: sql`coalesce(excluded.${sql.identifier(GameDownloadTable.totalBytes.name)}, ${GameDownloadTable.totalBytes})`,
errorMessage: sql`case when excluded.${sql.identifier(GameDownloadTable.status.name)} = 'failed' then coalesce(excluded.${sql.identifier(GameDownloadTable.errorMessage.name)}, ${GameDownloadTable.errorMessage}) else null end`,
timeStarted: sql`case when excluded.${sql.identifier(GameDownloadTable.status.name)} in ('downloading', 'verifying') then coalesce(${GameDownloadTable.timeStarted}, now()) else ${GameDownloadTable.timeStarted} end`,
timeCompleted: sql`case when excluded.${sql.identifier(GameDownloadTable.status.name)} = 'ready' then now() else null end`
}
})
.returning();
return row;
});
}
);
export const findByHostAndGame = fn(Info.pick({ hostId: true, gameId: true }), async (input) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameDownloadTable)
.where(
and(
eq(GameDownloadTable.hostId, input.hostId),
eq(GameDownloadTable.gameId, input.gameId),
isNull(GameDownloadTable.timeDeleted)
)
)
.then((rows) => rows.at(0) ?? null);
});
});
export const listByGame = fn(Info.shape.gameId, async (gameId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameDownloadTable)
.where(and(eq(GameDownloadTable.gameId, gameId), isNull(GameDownloadTable.timeDeleted)))
.orderBy(GameDownloadTable.timeCreated);
});
});
export const listByHost = fn(Info.shape.hostId, async (hostId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameDownloadTable)
.where(and(eq(GameDownloadTable.hostId, hostId), isNull(GameDownloadTable.timeDeleted)))
.orderBy(GameDownloadTable.timeCreated);
});
});
export const listByGameIDs = fn(z.array(z.string()), async (gameIds) => {
if (gameIds.length === 0) return [];
return Database.use(async (tx) => {
return tx
.select()
.from(GameDownloadTable)
.where(
and(inArray(GameDownloadTable.gameId, gameIds), isNull(GameDownloadTable.timeDeleted))
);
});
});
export const markReady = fn(Info.pick({ hostId: true, gameId: true }), async (input) => {
return Database.use(async (tx) => {
const [row] = await tx
.update(GameDownloadTable)
.set({ status: 'ready', timeCompleted: new Date() })
.where(
and(
eq(GameDownloadTable.hostId, input.hostId),
eq(GameDownloadTable.gameId, input.gameId),
isNull(GameDownloadTable.timeDeleted)
)
)
.returning();
return row ?? null;
});
});
export const markFailed = fn(
Info.pick({ hostId: true, gameId: true, errorMessage: true }),
async (input) => {
return Database.use(async (tx) => {
const [row] = await tx
.update(GameDownloadTable)
.set({
status: 'failed',
errorMessage: input.errorMessage ?? null
})
.where(
and(
eq(GameDownloadTable.hostId, input.hostId),
eq(GameDownloadTable.gameId, input.gameId),
isNull(GameDownloadTable.timeDeleted)
)
)
.returning();
return row ?? null;
});
}
);
export function serialize(input: typeof GameDownloadTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
hostId: input.hostId,
gameId: input.gameId,
status: input.status as Info['status'],
progressBytes: input.progressBytes,
totalBytes: input.totalBytes,
timeStarted: input.timeStarted?.toISOString() ?? null,
timeCompleted: input.timeCompleted?.toISOString() ?? null,
errorMessage: input.errorMessage
};
}
}

View File

@@ -0,0 +1,48 @@
import { bigint, integer, jsonb, pgTable, smallint, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, utc } from '../db/types.js';
export const GameTable = pgTable(
'game',
{
...id,
...timestamps,
steamAppId: integer('steam_app_id').notNull().unique(),
slug: text('slug').notNull(),
name: text('name').notNull(),
type: text('type'),
clientIcon: text('client_icon'),
icon: text('icon'),
shortDescription: text('short_description'),
description: text('description'),
developers: jsonb('developers').$type<string[]>(),
publishers: jsonb('publishers').$type<string[]>(),
primaryGenre: text('primary_genre'),
genres: jsonb('genres').$type<string[]>(),
categories: jsonb('categories').$type<string[]>(),
oslist: jsonb('oslist').$type<string[]>(),
sizeDownload: bigint('size_download', { mode: 'number' }),
sizeOnDisk: bigint('size_on_disk', { mode: 'number' }),
controllerSupport: text('controller_support'),
steamDeckCompat: text('steam_deck_compat'),
reviewScorePercent: smallint('review_score_percent'),
reviewCount: integer('review_count'),
metacriticScore: smallint('metacritic_score'),
steamChangeNumber: integer('steam_change_number'),
publicBuildId: integer('public_build_id'),
releaseDate: utc('release_date_utc'),
timeEnriched: utc('time_enriched')
},
(t) => [
uniqueIndex('game_slug_unique').on(t.slug),
uniqueIndex('game_app_id_unique').on(t.steamAppId)
]
);

View File

@@ -0,0 +1,388 @@
import { eq, and, isNull, sql, inArray } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { GameDownload } from './download.js';
import { GameTable } from './game.sql.js';
export { GameDownload };
export namespace Game {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the game',
example: Examples.Game.id
}),
steamAppId: z.number().int().meta({
description: 'Steam application ID',
example: Examples.Game.steamAppId
}),
slug: z.string().meta({
description: 'URL-friendly slug',
example: Examples.Game.slug
}),
name: z.string().meta({
description: 'Game title',
example: Examples.Game.name
}),
type: z.string().nullable().optional().meta({
description: 'Content type (game, dlc, demo, tool)',
example: Examples.Game.type
}),
clientIcon: z.string().nullable().optional().meta({
description: 'Steam client icon hash (256×256 square)',
example: Examples.Game.clientIcon
}),
icon: z.string().nullable().optional().meta({
description: 'Steam icon hash (32×32)',
example: Examples.Game.icon
}),
shortDescription: z.string().nullable().optional().meta({
description: 'Short marketing description',
example: Examples.Game.shortDescription
}),
description: z.string().nullable().optional().meta({
description: 'Full game description',
example: Examples.Game.description
}),
developers: z.array(z.string()).nullable().optional().meta({
description: 'Game developers',
example: Examples.Game.developers
}),
publishers: z.array(z.string()).nullable().optional().meta({
description: 'Game publishers',
example: Examples.Game.publishers
}),
primaryGenre: z.string().nullable().optional().meta({
description: 'Primary genre label',
example: Examples.Game.primaryGenre
}),
genres: z.array(z.string()).nullable().optional().meta({
description: 'All genre labels',
example: Examples.Game.genres
}),
categories: z.array(z.string()).nullable().optional().meta({
description: 'Steam store categories (Multi-player, Achievements, etc.)',
example: Examples.Game.categories
}),
oslist: z.array(z.string()).nullable().optional().meta({
description: 'Supported operating systems',
example: Examples.Game.oslist
}),
sizeDownload: z.number().nullable().optional().meta({
description: 'Compressed download size in bytes',
example: Examples.Game.sizeDownload
}),
sizeOnDisk: z.number().nullable().optional().meta({
description: 'Uncompressed install size in bytes',
example: Examples.Game.sizeOnDisk
}),
controllerSupport: z.string().nullable().optional().meta({
description: 'Controller support level',
example: Examples.Game.controllerSupport
}),
steamDeckCompat: z.string().nullable().optional().meta({
description: 'Steam Deck compatibility rating',
example: Examples.Game.steamDeckCompat
}),
reviewScorePercent: z.number().int().nullable().optional().meta({
description: 'Review score percentage (0100)',
example: Examples.Game.reviewScorePercent
}),
reviewCount: z.number().int().nullable().optional().meta({
description: 'Total review count',
example: Examples.Game.reviewCount
}),
metacriticScore: z.number().int().nullable().optional().meta({
description: 'Metacritic score',
example: Examples.Game.metacriticScore
}),
steamChangeNumber: z.number().int().nullable().optional().meta({
description: 'PICS change number for current version',
example: Examples.Game.steamChangeNumber
}),
publicBuildId: z.number().int().nullable().optional().meta({
description: 'Public branch build ID',
example: Examples.Game.publicBuildId
}),
releaseDate: z.string().nullable().optional().meta({
description: 'Release date (ISO 8601)',
example: Examples.Game.releaseDate
}),
timeEnriched: z.string().nullable().optional().meta({
description: 'When full metadata was last enriched from PICS',
example: Examples.Game.timeEnriched
})
})
.meta({
ref: 'Game',
description: 'A game in the global catalog',
example: Examples.Game
});
export type Info = z.infer<typeof Info>;
export const create = fn(
Info.pick({
id: true,
steamAppId: true,
slug: true,
name: true,
type: true,
clientIcon: true,
icon: true,
shortDescription: true,
description: true,
developers: true,
publishers: true,
primaryGenre: true,
genres: true,
categories: true,
oslist: true,
sizeDownload: true,
sizeOnDisk: true,
controllerSupport: true,
steamDeckCompat: true,
reviewScorePercent: true,
reviewCount: true,
metacriticScore: true,
steamChangeNumber: true,
publicBuildId: true,
releaseDate: true,
timeEnriched: true
}),
async (input) => {
await Database.use(async (tx) => {
await tx.insert(GameTable).values({
id: input.id,
steamAppId: input.steamAppId,
slug: input.slug,
name: input.name,
type: input.type ?? null,
clientIcon: input.clientIcon ?? null,
icon: input.icon ?? null,
shortDescription: input.shortDescription ?? null,
description: input.description ?? null,
developers: input.developers ?? null,
publishers: input.publishers ?? null,
primaryGenre: input.primaryGenre ?? null,
genres: input.genres ?? null,
categories: input.categories ?? null,
oslist: input.oslist ?? null,
sizeDownload: input.sizeDownload ?? null,
sizeOnDisk: input.sizeOnDisk ?? null,
controllerSupport: input.controllerSupport ?? null,
steamDeckCompat: input.steamDeckCompat ?? null,
reviewScorePercent: input.reviewScorePercent ?? null,
reviewCount: input.reviewCount ?? null,
metacriticScore: input.metacriticScore ?? null,
steamChangeNumber: input.steamChangeNumber ?? null,
publicBuildId: input.publicBuildId ?? null,
releaseDate: input.releaseDate ? new Date(input.releaseDate) : null,
timeEnriched: input.timeEnriched ? new Date(input.timeEnriched) : null
});
});
return input.id;
}
);
export const fromID = fn(Info.shape.id, async (id) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameTable)
.where(and(eq(GameTable.id, id), isNull(GameTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
});
export const fromSteamAppID = fn(z.number().int(), async (steamAppId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameTable)
.where(and(eq(GameTable.steamAppId, steamAppId), isNull(GameTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
});
export const upsert = fn(
Info.pick({
id: true,
steamAppId: true,
slug: true,
name: true,
type: true,
clientIcon: true,
icon: true,
shortDescription: true,
description: true,
developers: true,
publishers: true,
primaryGenre: true,
genres: true,
categories: true,
oslist: true,
sizeDownload: true,
sizeOnDisk: true,
controllerSupport: true,
steamDeckCompat: true,
reviewScorePercent: true,
reviewCount: true,
metacriticScore: true,
steamChangeNumber: true,
publicBuildId: true,
releaseDate: true,
timeEnriched: true
}),
async (input) =>
Database.use(async (tx) =>
tx
.insert(GameTable)
.values({
id: input.id,
steamAppId: input.steamAppId,
slug: input.slug,
name: input.name,
type: input.type ?? null,
clientIcon: input.clientIcon ?? null,
icon: input.icon ?? null,
shortDescription: input.shortDescription ?? null,
description: input.description ?? null,
developers: input.developers ?? null,
publishers: input.publishers ?? null,
primaryGenre: input.primaryGenre ?? null,
genres: input.genres ?? null,
categories: input.categories ?? null,
oslist: input.oslist ?? null,
sizeDownload: input.sizeDownload ?? null,
sizeOnDisk: input.sizeOnDisk ?? null,
controllerSupport: input.controllerSupport ?? null,
steamDeckCompat: input.steamDeckCompat ?? null,
reviewScorePercent: input.reviewScorePercent ?? null,
reviewCount: input.reviewCount ?? null,
metacriticScore: input.metacriticScore ?? null,
steamChangeNumber: input.steamChangeNumber ?? null,
publicBuildId: input.publicBuildId ?? null,
releaseDate: input.releaseDate ? new Date(input.releaseDate) : null,
timeEnriched: input.timeEnriched ? new Date(input.timeEnriched) : null
})
.onConflictDoUpdate({
target: GameTable.steamAppId,
set: {
slug: input.slug,
name: input.name,
type: input.type ?? null,
clientIcon: input.clientIcon ?? null,
icon: input.icon ?? null,
shortDescription: input.shortDescription ?? null,
description: input.description ?? null,
developers: input.developers ?? null,
publishers: input.publishers ?? null,
primaryGenre: input.primaryGenre ?? null,
genres: input.genres ?? null,
categories: input.categories ?? null,
oslist: input.oslist ?? null,
sizeDownload: input.sizeDownload ?? null,
sizeOnDisk: input.sizeOnDisk ?? null,
controllerSupport: input.controllerSupport ?? null,
steamDeckCompat: input.steamDeckCompat ?? null,
reviewScorePercent: input.reviewScorePercent ?? null,
reviewCount: input.reviewCount ?? null,
metacriticScore: input.metacriticScore ?? null,
steamChangeNumber: input.steamChangeNumber ?? null,
publicBuildId: input.publicBuildId ?? null,
releaseDate: input.releaseDate ? new Date(input.releaseDate) : null,
timeEnriched: input.timeEnriched ? new Date(input.timeEnriched) : null
}
})
.returning()
)
);
export const searchByName = fn(z.string(), async (query) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameTable)
.where(
and(isNull(GameTable.timeDeleted), sql`${GameTable.name} ILIKE ${'%' + query + '%'}`)
)
.orderBy(GameTable.name)
.limit(50);
});
});
export const listUnenriched = fn(z.number().int().default(50), async (limit) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameTable)
.where(and(isNull(GameTable.timeEnriched), isNull(GameTable.timeDeleted)))
.limit(limit);
});
});
export const listByIDs = fn(z.array(z.string()), async (ids) => {
if (ids.length === 0) return [];
return Database.use(async (tx) => {
return tx
.select()
.from(GameTable)
.where(and(inArray(GameTable.id, ids), isNull(GameTable.timeDeleted)));
});
});
export const listByAppIDs = fn(z.array(z.number().int()), async (appIds) => {
if (appIds.length === 0) return [];
return Database.use(async (tx) => {
return tx
.select()
.from(GameTable)
.where(and(inArray(GameTable.steamAppId, appIds), isNull(GameTable.timeDeleted)));
});
});
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(GameTable)
.set({ timeDeleted: sql`now()` })
.where(eq(GameTable.id, id));
});
});
export function serialize(input: typeof GameTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
steamAppId: input.steamAppId,
slug: input.slug,
name: input.name,
type: input.type,
clientIcon: input.clientIcon,
icon: input.icon,
shortDescription: input.shortDescription,
description: input.description,
developers: input.developers,
publishers: input.publishers,
primaryGenre: input.primaryGenre,
genres: input.genres,
categories: input.categories,
oslist: input.oslist,
sizeDownload: input.sizeDownload,
sizeOnDisk: input.sizeOnDisk,
controllerSupport: input.controllerSupport,
steamDeckCompat: input.steamDeckCompat,
reviewScorePercent: input.reviewScorePercent,
reviewCount: input.reviewCount,
metacriticScore: input.metacriticScore,
steamChangeNumber: input.steamChangeNumber,
publicBuildId: input.publicBuildId,
releaseDate: input.releaseDate?.toISOString() ?? null,
timeEnriched: input.timeEnriched?.toISOString() ?? null
};
}
}