mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat: Sync to OSS repo
This commit is contained in:
48
packages/core/src/game/depot.sql.ts
Normal file
48
packages/core/src/game/depot.sql.ts
Normal 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}`)
|
||||
]
|
||||
);
|
||||
266
packages/core/src/game/depot.ts
Normal file
266
packages/core/src/game/depot.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
38
packages/core/src/game/download.sql.ts
Normal file
38
packages/core/src/game/download.sql.ts
Normal 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)
|
||||
]
|
||||
);
|
||||
230
packages/core/src/game/download.test.ts
Normal file
230
packages/core/src/game/download.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
207
packages/core/src/game/download.ts
Normal file
207
packages/core/src/game/download.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
48
packages/core/src/game/game.sql.ts
Normal file
48
packages/core/src/game/game.sql.ts
Normal 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)
|
||||
]
|
||||
);
|
||||
388
packages/core/src/game/index.ts
Normal file
388
packages/core/src/game/index.ts
Normal 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 (0–100)',
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user