feat: migrate legacy long-lived database records

This commit is contained in:
2026-07-27 01:12:00 +00:00
parent d8220a18b3
commit 89013272e1
39 changed files with 2495 additions and 124 deletions
+2
View File
@@ -24,6 +24,7 @@ import { rankingRouter } from './router/ranking/index.js';
import { dynastyRouter } from './router/dynasty/index.js';
import { voteRouter } from './router/vote/index.js';
import { bettingRouter } from './router/betting/index.js';
import { archiveRouter } from './router/archive/index.js';
export const appRouter = router({
health: healthRouter,
@@ -50,6 +51,7 @@ export const appRouter = router({
dynasty: dynastyRouter,
vote: voteRouter,
betting: bettingRouter,
archive: archiveRouter,
});
export type AppRouter = typeof appRouter;
+118
View File
@@ -0,0 +1,118 @@
import { asRecord } from '@sammo-ts/common';
import { readOnlyAuthedProcedure, router } from '../../trpc.js';
const numberOrNull = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
const textOrNull = (value: unknown): string | null => (typeof value === 'string' ? value : null);
export const archiveRouter = router({
myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => {
const owner = ctx.auth?.user.id;
if (!owner) {
throw new Error('Authenticated archive query is missing its user identity');
}
const generals = await ctx.db.oldGeneral.findMany({
where: { owner },
orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
});
if (!generals.length) {
return { seasons: [] };
}
const serverIds = [...new Set(generals.map((general) => general.serverId))];
const [games, nations] = await Promise.all([
ctx.db.gameHistory.findMany({
where: { serverId: { in: serverIds } },
}),
ctx.db.oldNation.findMany({
where: { serverId: { in: serverIds } },
orderBy: [{ date: 'desc' }, { id: 'desc' }],
}),
]);
const gameByServer = new Map(games.map((game) => [game.serverId, game]));
const nationByServerAndId = new Map<string, (typeof nations)[number]>();
for (const nation of nations) {
const key = `${nation.serverId}:${nation.nation}`;
if (!nationByServerAndId.has(key)) {
nationByServerAndId.set(key, nation);
}
}
const seasons = new Map<
string,
{
serverId: string;
date: string | null;
season: number | null;
scenario: number | null;
scenarioName: string | null;
generals: Array<{
generalNo: number;
name: string;
lastYearMonth: number;
nationId: number;
nationName: string;
nationColor: string;
leadership: number | null;
strength: number | null;
intel: number | null;
experience: number | null;
dedication: number | null;
officerLevel: number | null;
personal: string | null;
special: string | null;
special2: string | null;
}>;
}
>();
for (const general of generals) {
const game = gameByServer.get(general.serverId);
let season = seasons.get(general.serverId);
if (!season) {
season = {
serverId: general.serverId,
date: game?.date.toISOString() ?? null,
season: game?.season ?? null,
scenario: game?.scenario ?? null,
scenarioName: game?.scenarioName ?? null,
generals: [],
};
seasons.set(general.serverId, season);
}
const data = asRecord(general.data);
const nationId = numberOrNull(data.nation) ?? 0;
const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`);
const nationData = asRecord(nation?.data);
season.generals.push({
generalNo: general.generalNo,
name: general.name,
lastYearMonth: general.lastYearMonth,
nationId,
nationName: textOrNull(nationData.name) ?? (nationId === 0 ? '재야' : '미상'),
nationColor: textOrNull(nationData.color) ?? '#000000',
leadership: numberOrNull(data.leadership),
strength: numberOrNull(data.strength),
intel: numberOrNull(data.intel),
experience: numberOrNull(data.experience),
dedication: numberOrNull(data.dedication),
officerLevel: numberOrNull(data.officer_level),
personal: textOrNull(data.personal),
special: textOrNull(data.special),
special2: textOrNull(data.special2),
});
}
return {
seasons: [...seasons.values()].sort((left, right) => {
const leftTime = left.date ? new Date(left.date).getTime() : 0;
const rightTime = right.date ? new Date(right.date).getTime() : 0;
return rightTime - leftTime || right.serverId.localeCompare(left.serverId);
}),
};
}),
});
+18 -19
View File
@@ -22,8 +22,10 @@ type YearbookNation = {
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
const zServerId = z.string().trim().min(1).max(64);
const computeHash = (payload: unknown): string =>
createHash('sha256').update(JSON.stringify(payload)).digest('hex');
const computeHash = (payload: unknown): string => createHash('sha256').update(JSON.stringify(payload)).digest('hex');
const parseTextArray = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
const parseYearbookNations = (value: unknown): YearbookNation[] => {
if (!Array.isArray(value)) {
@@ -103,8 +105,7 @@ const buildNationSnapshot = async (ctx: GameApiContext) => {
for (const city of cityRows) {
const entry = cityStatsByNation.get(city.nationId) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
const valueSum =
city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence;
const valueSum = city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence;
const maxSum =
city.populationMax +
city.agricultureMax +
@@ -229,12 +230,8 @@ export const yearbookRouter = router({
const currentYearMonth = joinYearMonth(worldState.currentYear, worldState.currentMonth);
const fallbackYearMonth = currentYearMonth - 1;
const firstYearMonth = firstRow
? joinYearMonth(firstRow.year, firstRow.month)
: fallbackYearMonth;
const lastYearMonth = lastRow
? joinYearMonth(lastRow.year, lastRow.month)
: fallbackYearMonth;
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
const selectedYearMonth = isCurrentProfile ? currentYearMonth : lastYearMonth;
return {
@@ -262,17 +259,10 @@ export const yearbookRouter = router({
const isCurrentProfile = targetProfileName === ctx.profile.name;
const isCurrent =
isCurrentProfile &&
worldState.currentYear === input.year && worldState.currentMonth === input.month;
const { globalHistory, globalAction } = isCurrentProfile
? await buildLogs(ctx, input.year, input.month)
: {
globalHistory: [`<C>●</>${input.month}월: 기록 없음`],
globalAction: [`<C>●</>${input.month}월: 기록 없음`],
};
isCurrentProfile && worldState.currentYear === input.year && worldState.currentMonth === input.month;
if (isCurrent) {
const { globalHistory, globalAction } = await buildLogs(ctx, input.year, input.month);
const map = await loadPublicMap(ctx, false);
if (!map) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World map is not available.' });
@@ -299,6 +289,7 @@ export const yearbookRouter = router({
year: input.year,
month: input.month,
},
orderBy: [{ sourceId: 'desc' }, { id: 'desc' }],
});
if (!row) {
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 데이터를 찾을 수 없습니다.' });
@@ -306,6 +297,14 @@ export const yearbookRouter = router({
const map = asRecord(row.map) as BaseMapResult;
const nations = parseYearbookNations(row.nations);
const archivedLogs =
isCurrentProfile && row.sourceId === 0
? await buildLogs(ctx, input.year, input.month)
: {
globalHistory: parseTextArray(row.globalHistory),
globalAction: parseTextArray(row.globalAction),
};
const { globalHistory, globalAction } = archivedLogs;
const data = {
year: input.year,
month: input.month,