Files
netris-nestri/packages/core/src/game/download.ts
2026-08-06 22:13:51 +03:00

208 lines
6.8 KiB
TypeScript

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
};
}
}