feat: migrate legacy long-lived database records
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-27T00:00:00.000Z',
|
||||
expiresAt: '2026-07-28T00:00:00.000Z',
|
||||
sessionId: 'archive-session',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'user-1',
|
||||
displayName: '테스터',
|
||||
roles: ['user'],
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const context = (session: GameSessionTokenPayload | null): GameApiContext => {
|
||||
const db = {
|
||||
oldGeneral: {
|
||||
findMany: async ({ where }: { where: { owner: string } }) =>
|
||||
where.owner === 'user-1'
|
||||
? [
|
||||
{
|
||||
id: 1,
|
||||
serverId: 'che_legacy_1',
|
||||
generalNo: 10,
|
||||
owner: 'user-1',
|
||||
name: '과거장수',
|
||||
lastYearMonth: 22012,
|
||||
turnTime: new Date('2025-01-01T00:00:00.000Z'),
|
||||
data: {
|
||||
nation: 3,
|
||||
leadership: 80,
|
||||
strength: 70,
|
||||
intel: 60,
|
||||
personal: 'che_의리',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
gameHistory: {
|
||||
findMany: async () => [
|
||||
{
|
||||
id: 1,
|
||||
serverId: 'che_legacy_1',
|
||||
date: new Date('2025-01-02T00:00:00.000Z'),
|
||||
winnerNation: 3,
|
||||
map: 'scenario',
|
||||
season: 1,
|
||||
scenario: 100,
|
||||
scenarioName: '테스트',
|
||||
env: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
oldNation: {
|
||||
findMany: async () => [
|
||||
{
|
||||
id: 1,
|
||||
serverId: 'che_legacy_1',
|
||||
nation: 3,
|
||||
sourceId: 5,
|
||||
data: { name: '촉', color: '#ff0000' },
|
||||
date: new Date('2025-01-02T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const redis = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
} as unknown as RedisConnector['client'];
|
||||
return {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis,
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
auth: session,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
};
|
||||
|
||||
describe('archive.myPastPlays', () => {
|
||||
it('requires authentication and returns only the authenticated owner archive', async () => {
|
||||
await expect(appRouter.createCaller(context(null)).archive.myPastPlays()).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
const result = await appRouter.createCaller(context(auth)).archive.myPastPlays();
|
||||
expect(result.seasons).toEqual([
|
||||
expect.objectContaining({
|
||||
serverId: 'che_legacy_1',
|
||||
scenarioName: '테스트',
|
||||
generals: [
|
||||
expect.objectContaining({
|
||||
name: '과거장수',
|
||||
nationName: '촉',
|
||||
leadership: 80,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -21,20 +21,26 @@ const archiveRows = [
|
||||
{
|
||||
id: 1,
|
||||
profileName: archiveServerId,
|
||||
sourceId: 101,
|
||||
year: 200,
|
||||
month: 1,
|
||||
map: { year: 200, month: 1, startYear: 190, cityList: [], nationList: [] },
|
||||
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1000, cities: ['성도'] }],
|
||||
globalHistory: ['<C>●</>1월: 기록 없음'],
|
||||
globalAction: ['<C>●</>1월: 기록 없음'],
|
||||
hash: 'archive-1',
|
||||
createdAt: new Date('2026-07-25T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
profileName: archiveServerId,
|
||||
sourceId: 102,
|
||||
year: 200,
|
||||
month: 2,
|
||||
map: { year: 200, month: 2, startYear: 190, cityList: [], nationList: [] },
|
||||
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1200, cities: ['성도'] }],
|
||||
globalHistory: ['<C>●</>2월: 기록 없음'],
|
||||
globalAction: ['<C>●</>2월: 기록 없음'],
|
||||
hash: 'archive-2',
|
||||
createdAt: new Date('2026-07-25T01:00:00.000Z'),
|
||||
},
|
||||
@@ -55,10 +61,7 @@ const authFor = (userId: string): GameSessionTokenPayload => ({
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildContext = (
|
||||
auth: GameSessionTokenPayload | null,
|
||||
options: { hasGeneral?: boolean } = {}
|
||||
): GameApiContext => {
|
||||
const buildContext = (auth: GameSessionTokenPayload | null, options: { hasGeneral?: boolean } = {}): GameApiContext => {
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: async ({ where }: { where: { userId: string } }) =>
|
||||
|
||||
Reference in New Issue
Block a user