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,
+121
View File
@@ -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 } }) =>
+3 -1
View File
@@ -670,9 +670,10 @@ export const createDatabaseTurnHooks = async (
deletedNationSnapshots.map((snapshot) =>
prisma.oldNation.upsert({
where: {
serverId_nation: {
serverId_nation_sourceId: {
serverId,
nation: snapshot.nation.id,
sourceId: 0,
},
},
update: {
@@ -695,6 +696,7 @@ export const createDatabaseTurnHooks = async (
create: {
serverId,
nation: snapshot.nation.id,
sourceId: 0,
data: {
nation: snapshot.nation.id,
name: snapshot.nation.name,
@@ -558,9 +558,10 @@ export const createUnificationHandler = (options: {
await prisma.oldNation.upsert({
where: {
serverId_nation: {
serverId_nation_sourceId: {
serverId,
nation: winnerNationId,
sourceId: 0,
},
},
update: {
@@ -583,6 +584,7 @@ export const createUnificationHandler = (options: {
create: {
serverId,
nation: winnerNationId,
sourceId: 0,
data: {
nation: winnerNationId,
name: winnerNation.name,
@@ -602,9 +604,10 @@ export const createUnificationHandler = (options: {
await prisma.oldNation.upsert({
where: {
serverId_nation: {
serverId_nation_sourceId: {
serverId,
nation: 0,
sourceId: 0,
},
},
update: {
@@ -627,6 +630,7 @@ export const createUnificationHandler = (options: {
create: {
serverId,
nation: 0,
sourceId: 0,
data: {
nation: 0,
name: '재야',
+3 -1
View File
@@ -160,10 +160,11 @@ export const createYearbookHandler = (options: {
await connector.prisma.yearbookHistory.upsert({
where: {
profileName_year_month: {
profileName_year_month_sourceId: {
profileName: options.profileName,
year: context.previousYear,
month: context.previousMonth,
sourceId: 0,
},
},
update: {
@@ -173,6 +174,7 @@ export const createYearbookHandler = (options: {
},
create: {
profileName: options.profileName,
sourceId: 0,
year: context.previousYear,
month: context.previousMonth,
map,
@@ -218,10 +218,11 @@ integration('monthly pre-update persistence', () => {
]);
const yearbookRow = await db.yearbookHistory.findUniqueOrThrow({
where: {
profileName_year_month: {
profileName_year_month_sourceId: {
profileName: yearbookProfile,
year: 200,
month: 12,
sourceId: 0,
},
},
});
@@ -361,7 +361,7 @@ integration('monthly wandering nation persistence', () => {
})
).toBe(0);
const archive = await db.oldNation.findUniqueOrThrow({
where: { serverId_nation: { serverId, nation: nationIds[1]! } },
where: { serverId_nation_sourceId: { serverId, nation: nationIds[1]!, sourceId: 0 } },
});
expect(archive.data).toMatchObject({
nation: nationIds[1],
+122
View File
@@ -0,0 +1,122 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const installArchive = async (page: Page) => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_archive');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') return response({ myGeneral: null });
if (operation === 'archive.myPastPlays') {
return response({
seasons: [
{
serverId: 'che_2024_01',
date: '2024-01-31T00:00:00.000Z',
season: 51,
scenario: 2,
scenarioName: '천하쟁패',
generals: [
{
generalNo: 17,
name: '관우',
lastYearMonth: 21403,
nationId: 2,
nationName: '촉',
nationColor: '#800000',
leadership: 91,
strength: 98,
intel: 77,
experience: 23000,
dedication: 1200,
officerLevel: 12,
personal: '대담',
special: '상재',
special2: '신산',
},
],
},
],
});
}
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
};
test('past plays is available without a current general and preserves desktop interaction geometry', async ({
page,
}) => {
await installArchive(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('past-plays');
const root = page.locator('.past-plays-page');
await expect(root).toBeVisible();
await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible();
await expect(page.getByText('천하쟁패 · 51기')).toBeVisible();
await expect(page.locator('.general-name')).toHaveText('관우');
const geometry = await root.evaluate((element) => {
const rect = element.getBoundingClientRect();
const titleRect = element.querySelector('.title-row')!.getBoundingClientRect();
const tableRect = element.querySelector('table')!.getBoundingClientRect();
const style = getComputedStyle(element);
return {
x: rect.x,
width: rect.width,
minHeight: rect.height,
title: { x: titleRect.x, y: titleRect.y, width: titleRect.width },
tableWidth: tableRect.width,
color: style.color,
backgroundColor: style.backgroundColor,
};
});
expect(geometry).toMatchObject({
x: 100,
width: 1000,
title: { x: 100, y: 0, width: 1000 },
tableWidth: 1000,
color: 'rgb(238, 238, 238)',
backgroundColor: 'rgb(21, 21, 21)',
});
const refresh = page.getByRole('button', { name: '새로고침' });
await refresh.hover();
expect(await refresh.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(135, 206, 235)');
await refresh.focus();
expect(await refresh.evaluate((element) => document.activeElement === element)).toBe(true);
const artifactRoot = process.env.PAST_PLAYS_ARTIFACT_DIR;
if (artifactRoot) {
const output = resolve(artifactRoot);
await mkdir(output, { recursive: true });
await writeFile(resolve(output, 'desktop-computed-dom.json'), `${JSON.stringify(geometry, null, 2)}\n`);
await page.screenshot({ path: resolve(output, 'desktop.png'), fullPage: true });
}
});
test('past plays keeps the legacy-width table scrollable on a mobile viewport', async ({ page }) => {
await installArchive(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('past-plays');
const scroll = page.locator('.table-scroll');
const metrics = await scroll.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
overflowX: getComputedStyle(element).overflowX,
}));
// The shared legacy shell keeps its historical 500 px minimum canvas.
expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 820, overflowX: 'auto' });
await expect(page.locator('.title-row')).toHaveCSS('flex-direction', 'column');
});
@@ -16,6 +16,7 @@ export default defineConfig({
'inGameMenus.spec.ts',
'nationOffices.spec.ts',
'directoryLists.spec.ts',
'pastPlays.spec.ts',
'nationGeneralSecret.spec.ts',
'npcPolicy.spec.ts',
'auction.spec.ts',
+9
View File
@@ -35,6 +35,7 @@ import NpcListView from '../views/NpcListView.vue';
import NationListView from '../views/NationListView.vue';
import GeneralListView from '../views/GeneralListView.vue';
import TrafficView from '../views/TrafficView.vue';
import PastPlaysView from '../views/PastPlaysView.vue';
import { useSessionStore } from '../stores/session';
import { trpc } from '../utils/trpc';
@@ -321,6 +322,14 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/past-plays',
name: 'past-plays',
component: PastPlaysView,
meta: {
requiresAuth: true,
},
},
{
path: '/survey',
name: 'survey',
+1
View File
@@ -281,6 +281,7 @@ onMounted(() => {
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
</div>
<div class="join-tabs">
<RouterLink class="simulator-link" to="/past-plays"> 지난 플레이</RouterLink>
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
+1
View File
@@ -151,6 +151,7 @@ watch(
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보&amp;설정</RouterLink>
<RouterLink class="ghost" to="/past-plays"> 지난 플레이</RouterLink>
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
>토너먼트</RouterLink
>
@@ -223,6 +223,7 @@ onMounted(() => {
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
<div class="title-row">
<span> </span>
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" @click="loadPage">새로고침</button>
</div>
@@ -0,0 +1,239 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type Archive = Awaited<ReturnType<typeof trpc.archive.myPastPlays.query>>;
const archive = ref<Archive | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const loadArchive = async () => {
if (loading.value) return;
loading.value = true;
error.value = null;
try {
archive.value = await trpc.archive.myPastPlays.query();
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '지난 플레이를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
const yearMonth = (value: number): string => `${Math.floor(value / 100)}${value % 100}`;
const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value));
onMounted(() => {
void loadArchive();
});
</script>
<template>
<main id="container" class="past-plays-page legacy-bg0">
<header class="title-row legacy-bg1">
<h1> 지난 플레이 보기</h1>
<nav>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" :disabled="loading" @click="loadArchive">새로고침</button>
</nav>
</header>
<p class="page-note">종료된 기수에 보관된 장수 기록입니다.</p>
<p v-if="error" class="error-row">{{ error }}</p>
<p v-else-if="loading && !archive" class="empty-row">불러오는 중...</p>
<p v-else-if="archive?.seasons.length === 0" class="empty-row">보관된 지난 플레이가 없습니다.</p>
<section v-for="season in archive?.seasons ?? []" :key="season.serverId" class="season-card">
<div class="season-heading legacy-bg2">
<strong>{{ season.serverId }}</strong>
<span>
{{ season.scenarioName ?? '시나리오 미상' }}
<template v-if="season.season !== null"> · {{ season.season }}</template>
</span>
</div>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>장수명</th>
<th>소속</th>
<th>마지막 기록</th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
<th>관직</th>
<th>성격</th>
<th>내정 특기</th>
<th>전투 특기</th>
</tr>
</thead>
<tbody>
<tr v-for="general in season.generals" :key="general.generalNo">
<td class="general-name">{{ general.name }}</td>
<td>
<span
class="nation-name"
:style="{ backgroundColor: general.nationColor, color: '#ffffff' }"
>
{{ general.nationName }}
</span>
</td>
<td>{{ yearMonth(general.lastYearMonth) }}</td>
<td>{{ valueOrDash(general.leadership) }}</td>
<td>{{ valueOrDash(general.strength) }}</td>
<td>{{ valueOrDash(general.intel) }}</td>
<td>{{ valueOrDash(general.officerLevel) }}</td>
<td>{{ valueOrDash(general.personal) }}</td>
<td>{{ valueOrDash(general.special) }}</td>
<td>{{ valueOrDash(general.special2) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
</template>
<style scoped>
.past-plays-page {
width: min(1000px, 100%);
min-height: 100vh;
margin: 0 auto;
color: #eee;
background: #151515;
}
.title-row,
.season-heading {
border: 1px solid #555;
background: #2b2b2b;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
}
.title-row h1 {
margin: 0;
color: skyblue;
font-size: 18px;
}
.title-row nav {
display: flex;
gap: 6px;
}
.legacy-button {
box-sizing: border-box;
min-height: 28px;
padding: 4px 9px;
border: 1px solid #777;
border-radius: 0;
color: #eee;
background: #333;
font: inherit;
text-decoration: none;
cursor: pointer;
}
.legacy-button:hover,
.legacy-button:focus-visible {
border-color: skyblue;
color: skyblue;
}
.legacy-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.page-note,
.empty-row,
.error-row {
margin: 0;
padding: 12px 10px;
border-inline: 1px solid #555;
border-bottom: 1px solid #555;
}
.page-note {
color: #bbb;
}
.error-row {
color: #ff8d8d;
}
.season-card {
margin-top: 12px;
}
.season-heading {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 7px 9px;
}
.season-heading strong {
color: skyblue;
}
.season-heading span {
color: #bbb;
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
min-width: 820px;
border-collapse: collapse;
table-layout: auto;
background: #191919;
}
th,
td {
padding: 6px 7px;
border: 1px solid #555;
text-align: center;
white-space: nowrap;
}
th {
color: #ddd;
background: #303030;
font-weight: 600;
}
.general-name {
color: skyblue;
font-weight: 600;
}
.nation-name {
display: inline-block;
min-width: 54px;
padding: 2px 5px;
border: 1px solid rgb(255 255 255 / 25%);
text-shadow: 0 1px 1px #000;
}
@media (max-width: 640px) {
.title-row,
.season-heading {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+100
View File
@@ -0,0 +1,100 @@
# Legacy MariaDB long-lived data migration
## Scope and safety boundary
`tools/legacy-db-migration` is the only supported importer. It has no HTTP
entrypoint, defaults to a read-only dry-run, and requires `--apply` for target
writes. Database URLs are accepted only through environment variables.
PostgreSQL advisory locks serialize an apply per profile, and every target row
uses a stable legacy key with `ON CONFLICT`, so an interrupted run is
repeatable.
The source of truth for eligibility is the checked ref schema, not every table
that happens to exist in an old dump. An extra root `config` table found in the
2026-07-27 dump is therefore retained only in the recovery dump.
### Gateway
| Legacy table | Target | Policy |
| --------------- | ----------------------------- | ------------------------------------------------------------------------------- |
| `member` | `app_user` plus `legacy_data` | Preserve identity, roles/ACL, sanctions, OAuth metadata, password hash and salt |
| `member_log` | `legacy_member_log` | Preserve complete JSON action history |
| `banned_member` | `legacy_banned_member` | Preserve hashed-email ban |
| `storage` | `legacy_root_key_value` | Preserve raw namespace/key/JSON value |
| `system` | `system` | Preserve registration/login switches and notice |
| `login_token` | none | Exclude expired bearer tokens, IP addresses and obsolete PHP sessions |
Legacy member numbers map to deterministic UUIDs. Existing rows are updated by
that UUID, so references such as `ng_old_generals.owner` remain stable even
when an old account was deleted before the dump.
Kakao members retain `oauth_id`, email and metadata. Cutover sets
`kakao_verified_at` and `kakao_grace_started_at` to the migration time, starting
the current verification policy from the migration instead of treating an old
Kakao login as unverified. Five source Kakao rows have no OAuth ID; their
metadata is retained for audit, but an absent provider identifier is not
invented.
Legacy password hashes remain usable when gateway-api has
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT`; a successful login upgrades the stored
value to Argon2id. A test-only account can instead be reset with the CLI and a
mode-0600 password file:
```sh
GATEWAY_DATABASE_URL=... pnpm migrate:legacy -- \
reset-password --login-id test-user --password-file /secure/password --apply
```
The password is never accepted as an argument or printed.
### Game profiles
| Legacy table | Target | Policy |
| ------------------------------- | ------------------------ | --------------------------------------------------------------------- |
| `ng_games` | `ng_games` | Preserve completed season metadata |
| `hall` | `hall` | Preserve hall-of-fame rows |
| `ng_old_generals` | `ng_old_generals` | Preserve full JSON snapshots and owner |
| `ng_old_nations` | `ng_old_nations` | Preserve all versions, including duplicate server/nation pairs |
| `emperior` | `emperior` | Preserve dynasty detail and legacy key |
| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key |
| `user_record` | `inheritance_log` | Preserve complete long-lived user record |
| persistent `storage` namespaces | `legacy_game_storage` | Preserve raw `inheritance_*` and `user_*` rows before projection |
| `storage:inheritance_point` | `inheritance_point` | Project the numeric first tuple item; retain the tuple in raw storage |
| `storage:user` | `inheritance_user_state` | Project known current inheritance state; retain raw storage |
| `ng_history` | `yearbook_history` | Preserve map, nation, global history and global action snapshots |
The source contains legitimate duplicate `(server_id, nation)` old-nation rows
and `(server_id, year, month)` history rows. `source_id` is consequently part of
the archive unique keys. Runtime-generated rows use `source_id = 0`; migrated
rows use the legacy primary key. This avoids a lossy last-row-wins upsert.
Current-season actor/world/queue/lock/message/market/vote state is explicitly
excluded. In particular, `general`, `city`, `nation`, their turn queues,
`general_record`, `world_history`, `statistic`, `board`, `comment`,
`diplomacy`, `ng_diplomacy`, `event`, `message`, `rank_data`, `troop`,
`tournament`, `vote`, `vote_comment`, season-scoped/unknown `storage`,
`ng_auction`, `ng_auction_bid`,
`ng_betting`, `reserved_open`, `select_pool`, `select_npc_token` and `plock`
must not be used to reconstruct a running season.
## Cutover procedure
1. Keep the original compressed dumps immutable and restore each source to a
private MariaDB instance.
2. Deploy the gateway and game Prisma migrations to empty staging databases.
3. Run gateway and each non-empty profile without `--apply`; archive the JSON
counts and excluded-table reasons.
4. Compare source counts, malformed JSON checks and duplicate natural-key
counts. Stop on unexplained drift.
5. Put the affected target in maintenance mode, take a PostgreSQL backup, then
run the same commands with `--apply`.
6. Repeat each apply. Counts must remain unchanged.
7. Verify Kakao migration timestamps, password-hash shapes, archive ownership,
old-nation/history duplicate preservation and the `/past-plays` authenticated
read path.
8. Retain the MariaDB dumps as rollback evidence. Rollback restores the
pre-cutover PostgreSQL backup; it does not reverse individual importer
upserts.
The supplied dump inventory has data for `che`, `hwe` and `root`. The `kwe`
directory is empty, so there is no KWE dataset to import or validate.
+2 -1
View File
@@ -20,7 +20,8 @@
"manage:general-icons": "node tools/manage-general-icons.mjs",
"check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check",
"check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check",
"test:e2e:frontend-legacy": "playwright test --config tools/frontend-legacy-parity/playwright.config.mjs --tsconfig tools/frontend-legacy-parity/tsconfig.json"
"test:e2e:frontend-legacy": "playwright test --config tools/frontend-legacy-parity/playwright.config.mjs --tsconfig tools/frontend-legacy-parity/tsconfig.json",
"migrate:legacy": "pnpm --filter @sammo-ts/legacy-db-migration migrate"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+1
View File
@@ -16,6 +16,7 @@
"prisma:generate:game": "prisma generate --schema prisma/game.prisma",
"prisma:generate:gateway": "prisma generate --schema prisma/gateway.prisma",
"prisma:migrate:deploy:game": "prisma migrate deploy --schema prisma/game.prisma",
"prisma:migrate:deploy:gateway": "PRISMA_SCHEMA=prisma/gateway.prisma prisma migrate deploy --schema prisma/gateway.prisma --config prisma.gateway.config.ts",
"prisma:migrate:status:game": "prisma migrate status --schema prisma/game.prisma",
"prisma:db:push:game": "prisma db push --schema prisma/game.prisma",
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
+18
View File
@@ -0,0 +1,18 @@
import 'dotenv/config';
import { defineConfig } from 'prisma/config';
const databaseUrl = process.env.GATEWAY_DATABASE_URL ?? process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('GATEWAY_DATABASE_URL or DATABASE_URL is required for gateway migrations.');
}
export default defineConfig({
schema: 'prisma/gateway.prisma',
migrations: {
path: 'prisma/gateway-migrations',
},
datasource: {
url: databaseUrl,
},
});
+26 -2
View File
@@ -277,10 +277,11 @@ model OldNation {
id Int @id @default(autoincrement())
serverId String @map("server_id")
nation Int @default(0)
sourceId Int @default(0) @map("source_id")
data Json @default(dbgenerated("'{}'::jsonb"))
date DateTime @default(now())
@@unique([serverId, nation])
@@unique([serverId, nation, sourceId])
@@index([serverId, nation], name: "by_server")
@@map("ng_old_nations")
}
@@ -303,6 +304,7 @@ model OldGeneral {
model Emperor {
id Int @id @default(autoincrement()) @map("no")
legacyId Int? @unique @map("legacy_id")
serverId String? @map("server_id")
phase String? @default("")
nationCount String? @map("nation_count")
@@ -440,14 +442,17 @@ model DiplomacyLetter {
model YearbookHistory {
id Int @id @default(autoincrement())
profileName String @map("profile_name")
sourceId Int @default(0) @map("source_id")
year Int
month Int
map Json
nations Json
globalHistory Json @default(dbgenerated("'[]'::jsonb")) @map("global_history")
globalAction Json @default(dbgenerated("'[]'::jsonb")) @map("global_action")
hash String @default("")
createdAt DateTime @default(now()) @map("created_at")
@@unique([profileName, year, month])
@@unique([profileName, year, month, sourceId])
@@index([profileName, year, month])
@@map("yearbook_history")
}
@@ -553,7 +558,10 @@ model NationBet {
model InheritanceLog {
id Int @id @default(autoincrement())
legacyId Int? @unique @map("legacy_id")
userId String @map("user_id")
serverId String? @map("server_id")
logType String @default("inheritPoint") @map("log_type")
year Int
month Int
text String
@@ -565,6 +573,7 @@ model InheritanceLog {
model InheritanceResult {
id Int @id @default(autoincrement())
legacyId Int? @unique @map("legacy_id")
serverId String @map("server_id")
owner String @map("owner")
generalId Int @map("general_id")
@@ -624,6 +633,21 @@ model InheritanceUserState {
@@map("inheritance_user_state")
}
model LegacyGameStorage {
id Int @id @default(autoincrement())
sourceId Int @map("source_id")
namespace String
key String
value Json
scope String
migratedAt DateTime @default(now()) @map("migrated_at")
@@unique([sourceId])
@@unique([namespace, key])
@@index([scope, namespace])
@@map("legacy_game_storage")
}
model BoardPost {
id Int @id @default(autoincrement())
nationId Int @map("nation_id")
@@ -0,0 +1,125 @@
DO $$
BEGIN
CREATE TYPE "OAuthType" AS ENUM ('NONE', 'KAKAO');
EXCEPTION
WHEN duplicate_object THEN NULL;
END
$$;
DO $$
BEGIN
CREATE TYPE "GatewayProfileStatus" AS ENUM (
'RESERVED', 'PREOPEN', 'RUNNING', 'PAUSED', 'COMPLETED', 'STOPPED', 'DISABLED'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END
$$;
DO $$
BEGIN
CREATE TYPE "GatewayBuildStatus" AS ENUM ('IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED');
EXCEPTION
WHEN duplicate_object THEN NULL;
END
$$;
DO $$
BEGIN
CREATE TYPE "GatewayOperationType" AS ENUM ('RESET', 'START', 'STOP');
EXCEPTION
WHEN duplicate_object THEN NULL;
END
$$;
DO $$
BEGIN
CREATE TYPE "GatewayOperationStatus" AS ENUM ('QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED');
EXCEPTION
WHEN duplicate_object THEN NULL;
END
$$;
DO $$
BEGIN
CREATE TYPE "GatewaySourceMode" AS ENUM ('BRANCH', 'COMMIT');
EXCEPTION
WHEN duplicate_object THEN NULL;
END
$$;
CREATE TABLE IF NOT EXISTS "app_user" (
"id" TEXT PRIMARY KEY,
"login_id" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"password_salt" TEXT NOT NULL,
"roles" JSONB NOT NULL DEFAULT '[]'::jsonb,
"sanctions" JSONB NOT NULL DEFAULT '{}'::jsonb,
"oauth_type" "OAuthType" NOT NULL DEFAULT 'NONE',
"oauth_id" TEXT,
"email" TEXT,
"oauth_info" JSONB NOT NULL DEFAULT '{}'::jsonb,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"last_login_at" TIMESTAMP(3)
);
CREATE UNIQUE INDEX IF NOT EXISTS "app_user_login_id_key" ON "app_user" ("login_id");
CREATE UNIQUE INDEX IF NOT EXISTS "app_user_oauth_id_key" ON "app_user" ("oauth_id");
CREATE UNIQUE INDEX IF NOT EXISTS "app_user_email_key" ON "app_user" ("email");
CREATE TABLE IF NOT EXISTS "gateway_profile" (
"profile_name" TEXT PRIMARY KEY,
"profile" TEXT NOT NULL,
"scenario" TEXT NOT NULL,
"api_port" INTEGER NOT NULL,
"status" "GatewayProfileStatus" NOT NULL,
"build_status" "GatewayBuildStatus" NOT NULL DEFAULT 'IDLE',
"build_commit_sha" TEXT,
"build_workspace" TEXT,
"build_last_used_at" TIMESTAMP(3),
"preopen_at" TIMESTAMP(3),
"open_at" TIMESTAMP(3),
"scheduled_start_at" TIMESTAMP(3),
"build_requested_at" TIMESTAMP(3),
"build_started_at" TIMESTAMP(3),
"build_completed_at" TIMESTAMP(3),
"build_error" TEXT,
"last_error" TEXT,
"meta" JSONB NOT NULL DEFAULT '{}'::jsonb,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "gateway_profile_profile_scenario_key"
ON "gateway_profile" ("profile", "scenario");
CREATE TABLE IF NOT EXISTS "gateway_operation" (
"id" TEXT PRIMARY KEY,
"profile_name" TEXT NOT NULL REFERENCES "gateway_profile" ("profile_name") ON DELETE CASCADE,
"type" "GatewayOperationType" NOT NULL,
"status" "GatewayOperationStatus" NOT NULL DEFAULT 'QUEUED',
"source_mode" "GatewaySourceMode",
"source_ref" TEXT,
"resolved_commit_sha" TEXT,
"payload" JSONB NOT NULL DEFAULT '{}'::jsonb,
"reason" TEXT,
"requested_by" TEXT NOT NULL,
"scheduled_at" TIMESTAMP(3),
"started_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
CREATE INDEX IF NOT EXISTS "gateway_operation_status_scheduled_at_created_at_idx"
ON "gateway_operation" ("status", "scheduled_at", "created_at");
CREATE INDEX IF NOT EXISTS "gateway_operation_profile_name_created_at_idx"
ON "gateway_operation" ("profile_name", "created_at");
CREATE TABLE IF NOT EXISTS "system" (
"no" INTEGER PRIMARY KEY DEFAULT 1,
"notice" TEXT NOT NULL DEFAULT ''
);
@@ -0,0 +1,39 @@
ALTER TABLE "app_user"
ADD COLUMN IF NOT EXISTS "legacy_data" JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE "system"
ADD COLUMN IF NOT EXISTS "registration_enabled" BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS "login_enabled" BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3);
CREATE TABLE IF NOT EXISTS "legacy_member_log" (
"id" BIGINT PRIMARY KEY,
"member_no" INTEGER NOT NULL,
"user_id" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"action_type" TEXT NOT NULL,
"action" JSONB
);
CREATE INDEX IF NOT EXISTS "legacy_member_log_user_date"
ON "legacy_member_log" ("user_id", "date");
CREATE INDEX IF NOT EXISTS "legacy_member_log_member_date"
ON "legacy_member_log" ("member_no", "date");
CREATE TABLE IF NOT EXISTS "legacy_banned_member" (
"no" INTEGER PRIMARY KEY,
"hashed_email" TEXT NOT NULL UNIQUE,
"info" TEXT
);
CREATE TABLE IF NOT EXISTS "legacy_root_key_value" (
"id" SERIAL PRIMARY KEY,
"source_table" TEXT NOT NULL,
"namespace" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"migrated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "legacy_root_key_value_source_namespace_key"
UNIQUE ("source_table", "namespace", "key")
);
+38
View File
@@ -74,10 +74,44 @@ model AppUser {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at")
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
@@map("app_user")
}
model LegacyMemberLog {
id BigInt @id
memberNo Int @map("member_no")
userId String @map("user_id")
date DateTime
actionType String @map("action_type")
action Json?
@@index([userId, date])
@@index([memberNo, date])
@@map("legacy_member_log")
}
model LegacyBannedMember {
id Int @id @map("no")
hashedEmail String @unique @map("hashed_email")
info String?
@@map("legacy_banned_member")
}
model LegacyRootKeyValue {
id Int @id @default(autoincrement())
sourceTable String @map("source_table")
namespace String
key String
value Json
migratedAt DateTime @default(now()) @map("migrated_at")
@@unique([sourceTable, namespace, key])
@@map("legacy_root_key_value")
}
model GatewayProfile {
profileName String @id @map("profile_name")
profile String
@@ -131,7 +165,11 @@ model GatewayOperation {
model SystemSetting {
id Int @id @default(1) @map("no")
registrationEnabled Boolean @default(false) @map("registration_enabled")
loginEnabled Boolean @default(false) @map("login_enabled")
notice String @default("") @map("notice")
createdAt DateTime? @map("created_at")
updatedAt DateTime? @map("updated_at")
@@map("system")
}
@@ -0,0 +1,48 @@
ALTER TABLE "ng_old_nations"
ADD COLUMN IF NOT EXISTS "source_id" INTEGER NOT NULL DEFAULT 0;
DROP INDEX IF EXISTS "ng_old_nations_server_id_nation";
CREATE UNIQUE INDEX IF NOT EXISTS "ng_old_nations_server_nation_source"
ON "ng_old_nations" ("server_id", "nation", "source_id");
ALTER TABLE "yearbook_history"
ADD COLUMN IF NOT EXISTS "source_id" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "global_history" JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS "global_action" JSONB NOT NULL DEFAULT '[]'::jsonb;
DROP INDEX IF EXISTS "yearbook_history_profile_year_month_key";
CREATE UNIQUE INDEX IF NOT EXISTS "yearbook_history_profile_year_month_source"
ON "yearbook_history" ("profile_name", "year", "month", "source_id");
ALTER TABLE "inheritance_log"
ADD COLUMN IF NOT EXISTS "legacy_id" INTEGER,
ADD COLUMN IF NOT EXISTS "server_id" TEXT,
ADD COLUMN IF NOT EXISTS "log_type" TEXT NOT NULL DEFAULT 'inheritPoint';
CREATE UNIQUE INDEX IF NOT EXISTS "inheritance_log_legacy_id_key"
ON "inheritance_log" ("legacy_id");
ALTER TABLE "emperior"
ADD COLUMN IF NOT EXISTS "legacy_id" INTEGER;
CREATE UNIQUE INDEX IF NOT EXISTS "emperior_legacy_id_key"
ON "emperior" ("legacy_id");
ALTER TABLE "inheritance_result"
ADD COLUMN IF NOT EXISTS "legacy_id" INTEGER;
CREATE UNIQUE INDEX IF NOT EXISTS "inheritance_result_legacy_id_key"
ON "inheritance_result" ("legacy_id");
CREATE TABLE IF NOT EXISTS "legacy_game_storage" (
"id" SERIAL PRIMARY KEY,
"source_id" INTEGER NOT NULL,
"namespace" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"scope" TEXT NOT NULL,
"migrated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "legacy_game_storage_source_id_key" UNIQUE ("source_id"),
CONSTRAINT "legacy_game_storage_namespace_key" UNIQUE ("namespace", "key")
);
CREATE INDEX IF NOT EXISTS "legacy_game_storage_scope_namespace"
ON "legacy_game_storage" ("scope", "namespace");
+53 -8
View File
@@ -480,6 +480,34 @@ importers:
specifier: ^4.0.16
version: 4.0.16(@types/node@26.1.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
tools/legacy-db-migration:
dependencies:
mariadb:
specifier: 3.5.3
version: 3.5.3
pg:
specifier: ^8.16.3
version: 8.16.3
devDependencies:
'@types/node':
specifier: ^26.1.1
version: 26.1.1
'@types/pg':
specifier: ^8.15.6
version: 8.16.0
tsdown:
specifier: ^0.22.14
version: 0.22.14(@volar/typescript@2.4.27)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.2.2(typescript@6.0.2))
tsx:
specifier: ^4.20.6
version: 4.21.0
typescript:
specifier: 6.0.2
version: 6.0.2
vitest:
specifier: ^4.0.16
version: 4.0.16(@types/node@26.1.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
packages:
'@babel/generator@7.28.5':
@@ -1981,6 +2009,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -3043,8 +3074,8 @@ packages:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.1:
resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==}
iconv-lite@0.7.3:
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
engines: {node: '>=0.10.0'}
ignore@5.3.2:
@@ -3256,8 +3287,8 @@ packages:
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
lru-cache@11.2.5:
resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==}
lru-cache@11.5.2:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22}
lru-cache@6.0.0:
@@ -3275,6 +3306,10 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mariadb@3.5.3:
resolution: {integrity: sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==}
engines: {node: '>= 20.0.0'}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
@@ -5601,6 +5636,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {}
'@types/json-schema@7.0.15': {}
'@types/linkify-it@5.0.0': {}
@@ -6674,7 +6711,7 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.1:
iconv-lite@0.7.3:
dependencies:
safer-buffer: 2.1.2
@@ -6832,7 +6869,7 @@ snapshots:
long@5.3.2: {}
lru-cache@11.2.5: {}
lru-cache@11.5.2: {}
lru-cache@6.0.0:
dependencies:
@@ -6846,6 +6883,14 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
mariadb@3.5.3:
dependencies:
'@types/geojson': 7946.0.16
'@types/node': 26.1.1
denque: 2.1.0
iconv-lite: 0.7.3
lru-cache: 11.5.2
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
@@ -6886,7 +6931,7 @@ snapshots:
aws-ssl-profiles: 1.1.2
denque: 2.1.0
generate-function: 2.3.1
iconv-lite: 0.7.1
iconv-lite: 0.7.3
long: 5.3.2
lru.min: 1.1.3
named-placeholders: 1.1.6
@@ -6991,7 +7036,7 @@ snapshots:
path-scurry@2.0.1:
dependencies:
lru-cache: 11.2.5
lru-cache: 11.5.2
minipass: 7.1.2
pathe@2.0.3: {}
+58
View File
@@ -0,0 +1,58 @@
# Legacy DB migration CLI
This package migrates the long-lived parts of a restored ref MariaDB database
into the core2026 PostgreSQL schemas. It is CLI-only; no HTTP or administrator
route invokes it.
The default mode is a read-only dry-run. `--apply` is required before any target
write. PostgreSQL advisory locks prevent two applies for the same target. Every
write uses a stable legacy key and `ON CONFLICT`, so a completed or interrupted
run can be repeated.
## Source restore
Restore each compressed table dump into a private MariaDB database before
running this tool. Do not expose that database on a public interface. The dump
directory is intentionally Git-ignored.
```sh
gzip -cd /path/to/db_dumps/root/member.sql.gz | mariadb root_dump
gzip -cd /path/to/db_dumps/che/ng_games.sql.gz | mariadb che_dump
```
Restore all tables defined by ref even though the CLI intentionally projects
only long-lived tables. This lets the dry-run verify the source inventory and
keeps the original dump as the recovery source.
Database URLs belong in a Git-ignored environment file or injected process
environment. They are deliberately not accepted as command-line flags.
## Commands
```sh
LEGACY_ROOT_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate gateway
LEGACY_GAME_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate game --profile che
```
After reviewing the JSON counts and excluded-table reasons, add
`GATEWAY_DATABASE_URL` or `GAME_DATABASE_URL` and repeat with `--apply`.
Kakao members retain their OAuth ID, email, and OAuth metadata.
`kakao_verified_at` and `kakao_grace_started_at` are set to the migration time,
which renews verification at cutover. Legacy password hashes and salts are
retained and upgraded to Argon2id after the first successful login when
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is configured in gateway-api.
Only tables present in the checked ref schemas are eligible. Extra tables found
in a dump, such as an old root `config` table, are left in the recovery dump and
are not silently imported.
For an isolated test account, put a temporary password in a mode-0600 file and
run:
```sh
GATEWAY_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate \
reset-password --login-id test-user --password-file /secure/path/password --apply
```
The password value is never accepted on the command line or printed.
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@sammo-ts/legacy-db-migration",
"private": true,
"version": "0.0.0",
"type": "module",
"bin": {
"sammo-migrate-legacy-db": "./dist/cli.mjs"
},
"scripts": {
"build": "tsdown src/cli.ts --no-config --format esm --out-dir dist --dts --sourcemap --target node22 --platform node",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc -b",
"migrate": "tsx src/cli.ts"
},
"dependencies": {
"mariadb": "3.5.3",
"pg": "^8.16.3"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/pg": "^8.15.6",
"tsdown": "^0.22.14",
"tsx": "^4.20.6",
"typescript": "6.0.2",
"vitest": "^4.0.16"
}
}
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env node
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { createMariaPool, createPostgresPool } from './db.js';
import { migrateGame } from './game.js';
import { migrateGateway } from './gateway.js';
import { hashPasswordForReset } from './password.js';
type Command = 'gateway' | 'game' | 'reset-password';
interface CliOptions {
command: Command;
apply: boolean;
profile?: string;
loginId?: string;
passwordFile?: string;
}
const usage = `Usage:
pnpm --filter @sammo-ts/legacy-db-migration migrate gateway [--apply]
pnpm --filter @sammo-ts/legacy-db-migration migrate game --profile <profile> [--apply]
pnpm --filter @sammo-ts/legacy-db-migration migrate reset-password --login-id <id> --password-file <path> --apply
Environment:
LEGACY_ROOT_DATABASE_URL MariaDB URL for the restored root dump
LEGACY_GAME_DATABASE_URL MariaDB URL for one restored game-profile dump
GATEWAY_DATABASE_URL target PostgreSQL URL for gateway
GAME_DATABASE_URL target PostgreSQL URL for the selected game profile
Dry-run is the default. Source and target URLs are accepted only through the environment so
credentials are not exposed in the process list.`;
const parseArguments = (argv: readonly string[]): CliOptions => {
const command = argv[0];
if (command !== 'gateway' && command !== 'game' && command !== 'reset-password') {
throw new Error(usage);
}
const options: CliOptions = { command, apply: false };
for (let index = 1; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--apply') {
options.apply = true;
continue;
}
const next = argv[index + 1];
if (!next || next.startsWith('--')) {
throw new Error(`Missing value for ${argument}\n\n${usage}`);
}
if (argument === '--profile') {
options.profile = next;
} else if (argument === '--login-id') {
options.loginId = next;
} else if (argument === '--password-file') {
options.passwordFile = next;
} else {
throw new Error(`Unknown argument: ${argument}\n\n${usage}`);
}
index += 1;
}
return options;
};
const requireEnvironment = (name: string): string => {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} is required`);
}
return value;
};
const resetPassword = async (options: CliOptions): Promise<Record<string, unknown>> => {
if (!options.apply) {
throw new Error('reset-password requires --apply');
}
if (!options.loginId || !options.passwordFile) {
throw new Error(`reset-password requires --login-id and --password-file\n\n${usage}`);
}
const passwordPath = path.resolve(options.passwordFile);
const passwordStat = await stat(passwordPath);
if ((passwordStat.mode & 0o077) !== 0) {
throw new Error('Password file must not be readable or writable by group/other (expected mode 0600)');
}
const password = (await readFile(passwordPath, 'utf8')).replace(/\r?\n$/, '');
if (!password) {
throw new Error('Password file is empty');
}
const pool = createPostgresPool(requireEnvironment('GATEWAY_DATABASE_URL'));
try {
const existing = await pool.query<{ id: string }>('SELECT "id" FROM "app_user" WHERE "login_id" = $1', [
options.loginId.toLowerCase(),
]);
if (existing.rowCount !== 1) {
throw new Error('Exactly one migrated account must match --login-id');
}
const hashed = await hashPasswordForReset(password);
await pool.query(
`UPDATE "app_user"
SET "password_hash" = $1, "password_salt" = $2, "updated_at" = CURRENT_TIMESTAMP
WHERE "id" = $3`,
[hashed.hash, hashed.salt, existing.rows[0]!.id]
);
return { command: 'reset-password', updated: 1, loginId: options.loginId.toLowerCase() };
} finally {
await pool.end();
}
};
const run = async (): Promise<void> => {
const options = parseArguments(process.argv.slice(2));
if (options.command === 'reset-password') {
console.log(JSON.stringify(await resetPassword(options), null, 2));
return;
}
const migratedAt = new Date();
if (options.command === 'gateway') {
const source = createMariaPool(requireEnvironment('LEGACY_ROOT_DATABASE_URL'));
const target = options.apply ? createPostgresPool(requireEnvironment('GATEWAY_DATABASE_URL')) : null;
try {
const summary = await migrateGateway(source, target, options.apply, migratedAt);
console.log(JSON.stringify(summary, null, 2));
} finally {
await source.end();
await target?.end();
}
return;
}
if (!options.profile || !/^[a-z][a-z0-9_-]{1,31}$/.test(options.profile)) {
throw new Error(`game requires a safe --profile value\n\n${usage}`);
}
const source = createMariaPool(requireEnvironment('LEGACY_GAME_DATABASE_URL'));
const target = options.apply ? createPostgresPool(requireEnvironment('GAME_DATABASE_URL')) : null;
try {
const summary = await migrateGame(source, target, options.apply, options.profile);
console.log(JSON.stringify(summary, null, 2));
} finally {
await source.end();
await target?.end();
}
};
run().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`[legacy-db-migration] ${message}`);
process.exitCode = 1;
});
+186
View File
@@ -0,0 +1,186 @@
import mariadb, { type Pool as MariaPool } from 'mariadb';
import pg, { type PoolClient } from 'pg';
export type SourceRow = Record<string, unknown>;
export type TargetRow = Record<string, unknown>;
class JsonParameter {
readonly value: unknown;
constructor(value: unknown) {
this.value = value;
}
}
export const jsonParameter = (value: unknown): JsonParameter => new JsonParameter(value);
const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
const MARIA_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
const quoteIdentifier = (value: string): string => {
if (!IDENTIFIER.test(value)) {
throw new Error(`Unsafe SQL identifier: ${value}`);
}
return `"${value}"`;
};
export const createMariaPool = (uri: string): MariaPool => mariadb.createPool(uri);
export const createPostgresPool = (connectionString: string): pg.Pool =>
new pg.Pool({
connectionString,
max: 2,
application_name: 'sammo-legacy-db-migration',
});
const isSourceRow = (value: unknown): value is SourceRow =>
value !== null && !Array.isArray(value) && typeof value === 'object';
export const querySource = async (
pool: MariaPool,
sql: string,
parameters: readonly unknown[] = []
): Promise<SourceRow[]> => {
const result: unknown = await pool.query(sql, [...parameters]);
if (!Array.isArray(result)) {
throw new Error('MariaDB query did not return rows');
}
return result.filter(isSourceRow);
};
export const paginateSource = async function* (
pool: MariaPool,
table: string,
idColumn: string,
batchSize: number
): AsyncGenerator<SourceRow[]> {
if (!MARIA_IDENTIFIER.test(table) || !MARIA_IDENTIFIER.test(idColumn)) {
throw new Error('Unsafe MariaDB table or ID column');
}
let lastId = -1n;
for (;;) {
const rows = await querySource(
pool,
`SELECT * FROM \`${table}\` WHERE \`${idColumn}\` > ? ORDER BY \`${idColumn}\` ASC LIMIT ?`,
[lastId.toString(), batchSize]
);
if (rows.length === 0) {
return;
}
yield rows;
lastId = toBigInt(rows.at(-1)?.[idColumn], `${table}.${idColumn}`);
}
};
const targetValue = (value: unknown): unknown => {
if (value instanceof JsonParameter) {
return JSON.stringify(value.value);
}
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
return JSON.stringify(value);
}
return value;
};
export const upsertRows = async (
client: PoolClient,
table: string,
rows: readonly TargetRow[],
conflictColumns: readonly string[]
): Promise<void> => {
if (rows.length === 0) {
return;
}
const columns = Object.keys(rows[0]!);
if (
columns.length === 0 ||
rows.some((row) => columns.some((column) => !(column in row))) ||
conflictColumns.some((column) => !columns.includes(column))
) {
throw new Error(`Inconsistent row shape for ${table}`);
}
const values: unknown[] = [];
const tuples = rows.map((row, rowIndex) => {
const placeholders = columns.map((column, columnIndex) => {
values.push(targetValue(row[column]));
return `$${rowIndex * columns.length + columnIndex + 1}`;
});
return `(${placeholders.join(', ')})`;
});
const updates = columns
.filter((column) => !conflictColumns.includes(column))
.map((column) => `${quoteIdentifier(column)} = EXCLUDED.${quoteIdentifier(column)}`);
const conflictAction = updates.length ? `DO UPDATE SET ${updates.join(', ')}` : 'DO NOTHING';
await client.query(
`INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')})
VALUES ${tuples.join(', ')}
ON CONFLICT (${conflictColumns.map(quoteIdentifier).join(', ')}) ${conflictAction}`,
values
);
};
export const withMigrationLock = async <T>(
client: PoolClient,
lockName: string,
operation: () => Promise<T>
): Promise<T> => {
await client.query('SELECT pg_advisory_lock(hashtext($1))', [lockName]);
try {
return await operation();
} finally {
await client.query('SELECT pg_advisory_unlock(hashtext($1))', [lockName]);
}
};
export const toNumber = (value: unknown, context: string): number => {
const number = typeof value === 'number' ? value : typeof value === 'bigint' ? Number(value) : Number(value);
if (!Number.isSafeInteger(number)) {
throw new Error(`${context}: expected a safe integer`);
}
return number;
};
export const toBigInt = (value: unknown, context: string): bigint => {
try {
return BigInt(String(value));
} catch (error) {
throw new Error(`${context}: expected an integer`, { cause: error });
}
};
export const toFloat = (value: unknown, context: string): number => {
const number = Number(value);
if (!Number.isFinite(number)) {
throw new Error(`${context}: expected a finite number`);
}
return number;
};
export const toStringValue = (value: unknown, context: string): string => {
if (typeof value !== 'string') {
throw new Error(`${context}: expected text`);
}
return value;
};
export const toNullableString = (value: unknown): string | null =>
value === null || value === undefined ? null : String(value);
export const toDate = (value: unknown, context: string): Date => {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value;
}
const text = String(value);
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(text)
? `${text.replace(' ', 'T')}Z`
: text;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
throw new Error(`${context}: invalid date`);
}
return date;
};
export const toNullableDate = (value: unknown, context: string): Date | null =>
value === null || value === undefined ? null : toDate(value, context);
+445
View File
@@ -0,0 +1,445 @@
import { createHash } from 'node:crypto';
import type { Pool as MariaPool } from 'mariadb';
import type { Pool as PgPool, PoolClient } from 'pg';
import {
paginateSource,
jsonParameter,
toDate,
toFloat,
toNullableDate,
toNullableString,
toNumber,
toStringValue,
upsertRows,
withMigrationLock,
type SourceRow,
type TargetRow,
} from './db.js';
import type { MigrationSummary } from './gateway.js';
import { legacyUserId } from './identity.js';
import {
classifyGameStorage,
parseInheritanceValue,
parseJson,
parseStorageUserId,
type JsonValue,
} from './transform.js';
const batchSize = 250;
const parseNullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue =>
value === null || value === undefined ? fallback : parseJson(value, context);
const parseJsonOrLegacyEmpty = (value: unknown, context: string): JsonValue =>
value === '' ? '' : parseJson(value, context);
const ownerId = (value: unknown): string | null => {
if (value === null || value === undefined) {
return null;
}
const memberNo = toNumber(value, 'legacy owner');
return memberNo > 0 ? legacyUserId(memberNo) : null;
};
const hashYearbook = (row: TargetRow): string =>
createHash('sha256')
.update(
JSON.stringify({
map: row.map,
nations: row.nations,
globalHistory: row.global_history,
globalAction: row.global_action,
})
)
.digest('hex');
const migrateSimpleTable = async (
source: MariaPool,
target: PoolClient | null,
sourceTable: string,
sourceIdColumn: string,
targetTable: string,
conflictColumns: readonly string[],
mapper: (row: SourceRow) => TargetRow,
counts: Record<string, number>,
size = batchSize
): Promise<void> => {
for await (const rows of paginateSource(source, sourceTable, sourceIdColumn, size)) {
const mapped = rows.map(mapper);
if (target) {
await upsertRows(target, targetTable, mapped, conflictColumns);
}
counts[sourceTable] = (counts[sourceTable] ?? 0) + mapped.length;
}
};
const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
migrateSimpleTable(
source,
target,
'hall',
'id',
'hall',
['server_id', 'type', 'general_no'],
(row) => {
const sourceId = toNumber(row.id, 'hall.id');
return {
server_id: toStringValue(row.server_id, `hall.${sourceId}.server_id`),
season: toNumber(row.season, `hall.${sourceId}.season`),
scenario: toNumber(row.scenario, `hall.${sourceId}.scenario`),
general_no: toNumber(row.general_no, `hall.${sourceId}.general_no`),
type: toStringValue(row.type, `hall.${sourceId}.type`),
value: toFloat(row.value, `hall.${sourceId}.value`),
owner: ownerId(row.owner),
aux: parseJson(row.aux, `hall.${sourceId}.aux`),
};
},
counts
);
const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
migrateSimpleTable(
source,
target,
'ng_games',
'id',
'ng_games',
['server_id'],
(row) => {
const sourceId = toNumber(row.id, 'ng_games.id');
return {
server_id: toStringValue(row.server_id, `ng_games.${sourceId}.server_id`),
date: toDate(row.date, `ng_games.${sourceId}.date`),
winner_nation:
row.winner_nation === null
? null
: toNumber(row.winner_nation, `ng_games.${sourceId}.winner_nation`),
map: toNullableString(row.map),
season: toNumber(row.season, `ng_games.${sourceId}.season`),
scenario: toNumber(row.scenario, `ng_games.${sourceId}.scenario`),
scenario_name: toStringValue(row.scenario_name, `ng_games.${sourceId}.scenario_name`),
env: parseJson(row.env, `ng_games.${sourceId}.env`),
};
},
counts
);
const migrateOldGenerals = (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> =>
migrateSimpleTable(
source,
target,
'ng_old_generals',
'id',
'ng_old_generals',
['server_id', 'general_no'],
(row) => {
const sourceId = toNumber(row.id, 'ng_old_generals.id');
return {
server_id: toStringValue(row.server_id, `ng_old_generals.${sourceId}.server_id`),
general_no: toNumber(row.general_no, `ng_old_generals.${sourceId}.general_no`),
owner: ownerId(row.owner),
name: toStringValue(row.name, `ng_old_generals.${sourceId}.name`),
last_yearmonth: toNumber(row.last_yearmonth, `ng_old_generals.${sourceId}.last_yearmonth`),
turntime: toDate(row.turntime, `ng_old_generals.${sourceId}.turntime`),
data: parseJson(row.data, `ng_old_generals.${sourceId}.data`),
};
},
counts
);
const migrateOldNations = (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> =>
migrateSimpleTable(
source,
target,
'ng_old_nations',
'id',
'ng_old_nations',
['server_id', 'nation', 'source_id'],
(row) => {
const sourceId = toNumber(row.id, 'ng_old_nations.id');
return {
server_id: toStringValue(row.server_id, `ng_old_nations.${sourceId}.server_id`),
nation: toNumber(row.nation, `ng_old_nations.${sourceId}.nation`),
source_id: sourceId,
data: parseJson(row.data, `ng_old_nations.${sourceId}.data`),
date: toDate(row.date, `ng_old_nations.${sourceId}.date`),
};
},
counts
);
const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
migrateSimpleTable(
source,
target,
'emperior',
'no',
'emperior',
['legacy_id'],
(row) => {
const id = toNumber(row.no, 'emperior.no');
return {
legacy_id: id,
server_id: toNullableString(row.server_id),
phase: toNullableString(row.phase),
nation_count: toNullableString(row.nation_count),
nation_name: toNullableString(row.nation_name),
nation_hist: toNullableString(row.nation_hist),
gen_count: toNullableString(row.gen_count),
personal_hist: toNullableString(row.personal_hist),
special_hist: toNullableString(row.special_hist),
name: toNullableString(row.name),
type: toNullableString(row.type),
color: toNullableString(row.color),
year: row.year === null ? null : toNumber(row.year, `emperior.${id}.year`),
month: row.month === null ? null : toNumber(row.month, `emperior.${id}.month`),
power: row.power === null ? null : toNumber(row.power, `emperior.${id}.power`),
gennum: row.gennum === null ? null : toNumber(row.gennum, `emperior.${id}.gennum`),
citynum: row.citynum === null ? null : toNumber(row.citynum, `emperior.${id}.citynum`),
pop: toNullableString(row.pop),
poprate: toNullableString(row.poprate),
gold: row.gold === null ? null : toNumber(row.gold, `emperior.${id}.gold`),
rice: row.rice === null ? null : toNumber(row.rice, `emperior.${id}.rice`),
l12name: toNullableString(row.l12name),
l12pic: toNullableString(row.l12pic),
l11name: toNullableString(row.l11name),
l11pic: toNullableString(row.l11pic),
l10name: toNullableString(row.l10name),
l10pic: toNullableString(row.l10pic),
l9name: toNullableString(row.l9name),
l9pic: toNullableString(row.l9pic),
l8name: toNullableString(row.l8name),
l8pic: toNullableString(row.l8pic),
l7name: toNullableString(row.l7name),
l7pic: toNullableString(row.l7pic),
l6name: toNullableString(row.l6name),
l6pic: toNullableString(row.l6pic),
l5name: toNullableString(row.l5name),
l5pic: toNullableString(row.l5pic),
tiger: toNullableString(row.tiger),
eagle: toNullableString(row.eagle),
gen: toNullableString(row.gen),
history: parseNullableJson(row.history, [], `emperior.${id}.history`),
aux: parseNullableJson(row.aux, {}, `emperior.${id}.aux`),
};
},
counts
);
const migrateInheritanceResults = (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> =>
migrateSimpleTable(
source,
target,
'inheritance_result',
'id',
'inheritance_result',
['legacy_id'],
(row) => {
const id = toNumber(row.id, 'inheritance_result.id');
return {
legacy_id: id,
server_id: toStringValue(row.server_id, `inheritance_result.${id}.server_id`),
owner: legacyUserId(toNumber(row.owner, `inheritance_result.${id}.owner`)),
general_id: toNumber(row.general_id, `inheritance_result.${id}.general_id`),
year: toNumber(row.year, `inheritance_result.${id}.year`),
month: toNumber(row.month, `inheritance_result.${id}.month`),
value: jsonParameter(parseJsonOrLegacyEmpty(row.value, `inheritance_result.${id}.value`)),
created_at: new Date(0),
};
},
counts
);
const migrateUserRecords = (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> =>
migrateSimpleTable(
source,
target,
'user_record',
'id',
'inheritance_log',
['legacy_id'],
(row) => {
const id = toNumber(row.id, 'user_record.id');
return {
legacy_id: id,
user_id: legacyUserId(toNumber(row.user_id, `user_record.${id}.user_id`)),
server_id: toStringValue(row.server_id, `user_record.${id}.server_id`),
log_type: toStringValue(row.log_type, `user_record.${id}.log_type`),
year: toNumber(row.year, `user_record.${id}.year`),
month: toNumber(row.month, `user_record.${id}.month`),
text: toStringValue(row.text, `user_record.${id}.text`),
created_at: toNullableDate(row.date, `user_record.${id}.date`) ?? new Date(0),
};
},
counts
);
const migrateYearbook = async (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> => {
await migrateSimpleTable(
source,
target,
'ng_history',
'no',
'yearbook_history',
['profile_name', 'year', 'month', 'source_id'],
(row) => {
const id = toNumber(row.no, 'ng_history.no');
const mapped: TargetRow = {
profile_name: toStringValue(row.server_id, `ng_history.${id}.server_id`),
source_id: id,
year: toNumber(row.year, `ng_history.${id}.year`),
month: toNumber(row.month, `ng_history.${id}.month`),
map: parseNullableJson(row.map, {}, `ng_history.${id}.map`),
nations: parseNullableJson(row.nations, [], `ng_history.${id}.nations`),
global_history: parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`),
global_action: parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`),
hash: '',
created_at: new Date(0),
};
mapped.hash = hashYearbook(mapped);
return mapped;
},
counts,
25
);
};
const migrateStorage = async (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> => {
for await (const rows of paginateSource(source, 'storage', 'id', batchSize)) {
const archives: TargetRow[] = [];
const points: TargetRow[] = [];
const userStates: TargetRow[] = [];
for (const row of rows) {
const sourceId = toNumber(row.id, 'storage.id');
const namespace = toStringValue(row.namespace, `storage.${sourceId}.namespace`);
const key = toStringValue(row.key, `storage.${sourceId}.key`);
const value = parseJson(row.value, `storage.${sourceId}.value`);
const scope = classifyGameStorage(namespace, key);
counts.storage_inspected = (counts.storage_inspected ?? 0) + 1;
if (scope === 'season-state') {
counts.storage_season_excluded = (counts.storage_season_excluded ?? 0) + 1;
continue;
}
archives.push({ source_id: sourceId, namespace, key, value: jsonParameter(value), scope });
const inheritanceUserId = parseStorageUserId(namespace, 'inheritance');
if (inheritanceUserId) {
points.push({
user_id: inheritanceUserId,
key,
value: parseInheritanceValue(value, `storage.${sourceId}.value`),
aux: {
legacyNamespace: namespace,
legacySourceId: sourceId,
legacyAux: Array.isArray(value) ? (value[1] ?? null) : null,
},
updated_at: new Date(0),
});
}
const stateUserId = parseStorageUserId(namespace, 'user');
if (stateUserId && key === 'last_stat_reset') {
userStates.push({
user_id: stateUserId,
meta: { lastStatReset: value, legacySourceId: sourceId },
updated_at: new Date(0),
});
}
}
if (target) {
await upsertRows(target, 'legacy_game_storage', archives, ['source_id']);
await upsertRows(target, 'inheritance_point', points, ['user_id', 'key']);
await upsertRows(target, 'inheritance_user_state', userStates, ['user_id']);
}
counts.storage_archived = (counts.storage_archived ?? 0) + archives.length;
counts.inheritance_point = (counts.inheritance_point ?? 0) + points.length;
counts.inheritance_user_state = (counts.inheritance_user_state ?? 0) + userStates.length;
}
};
export const migrateGame = async (
source: MariaPool,
targetPool: PgPool | null,
apply: boolean,
profile: string
): Promise<MigrationSummary> => {
const counts: Record<string, number> = {};
const excluded = {
general: 'Current-season actor state is intentionally not transferred.',
city: 'Current-season world state is intentionally not transferred.',
nation: 'Current-season nation state is intentionally not transferred.',
general_turn: 'Current-season command queue.',
general_access_log: 'Current-season access counters.',
nation_turn: 'Current-season nation command queue.',
nation_env: 'Current-season nation KV state.',
board: 'Current-season nation board.',
comment: 'Current-season nation board comments.',
diplomacy: 'Current-season diplomacy state.',
event: 'Current-season scheduled events.',
message: 'Current-season mailboxes.',
rank_data: 'Current-season ranking counters.',
statistic: 'Current-season statistics used to build permanent dynasty records.',
world_history: 'Current-season history; completed-month snapshots come from ng_history.',
general_record: 'Current-season general logs.',
ng_auction: 'Current-season auction.',
ng_auction_bid: 'Current-season auction bids.',
ng_betting: 'Current-season betting.',
ng_diplomacy: 'Current-season diplomacy letters.',
plock: 'Legacy process lock.',
reserved_open: 'Legacy opening schedule.',
select_npc_token: 'Ephemeral selection token.',
select_pool: 'Current-season selection pool.',
tournament: 'Current-season tournament.',
troop: 'Current-season troop state.',
vote: 'Current-season vote.',
vote_comment: 'Current-season vote comments.',
'storage:season-state': 'Only inheritance_* and user_* long-lived namespaces are archived or projected.',
};
const client = apply && targetPool ? await targetPool.connect() : null;
try {
const run = async (): Promise<void> => {
await migrateGames(source, client, counts);
await migrateHall(source, client, counts);
await migrateOldGenerals(source, client, counts);
await migrateOldNations(source, client, counts);
await migrateEmperors(source, client, counts);
await migrateInheritanceResults(source, client, counts);
await migrateUserRecords(source, client, counts);
await migrateStorage(source, client, counts);
await migrateYearbook(source, client, counts);
};
if (client) {
await withMigrationLock(client, `sammo-legacy-game-v1:${profile}`, run);
} else {
await run();
}
} finally {
client?.release();
}
return { command: 'game', apply, counts, excluded };
};
+223
View File
@@ -0,0 +1,223 @@
import type { Pool as MariaPool } from 'mariadb';
import type { Pool as PgPool, PoolClient } from 'pg';
import { legacyUserId } from './identity.js';
import {
paginateSource,
jsonParameter,
querySource,
toBigInt,
toDate,
toNullableDate,
toNullableString,
toNumber,
toStringValue,
upsertRows,
withMigrationLock,
type SourceRow,
type TargetRow,
} from './db.js';
import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js';
export interface MigrationSummary {
command: 'gateway' | 'game';
apply: boolean;
counts: Record<string, number>;
excluded: Record<string, string>;
}
const batchSize = 500;
const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => {
const memberNo = toNumber(row.NO, 'member.NO');
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
const acl = parseJson(row.acl, `member.${memberNo}.acl`);
const penalty = parseJson(row.penalty, `member.${memberNo}.penalty`);
const oauthInfo = parseJson(row.oauth_info, `member.${memberNo}.oauth_info`);
const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE';
const legacyData: JsonValue = {
memberNo,
grade,
acl,
penalty,
tokenValidUntil: toNullableString(row.token_valid_until),
regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`),
blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`),
blockDate: toNullableString(row.BLOCK_DATE),
};
return {
id: legacyUserId(memberNo),
login_id: toStringValue(row.ID, `member.${memberNo}.ID`).toLowerCase(),
display_name: toStringValue(row.NAME, `member.${memberNo}.NAME`),
password_hash: toStringValue(row.PW, `member.${memberNo}.PW`),
password_salt: toStringValue(row.salt, `member.${memberNo}.salt`),
roles: jsonParameter(mapLegacyRoles(grade, acl)),
sanctions: jsonParameter(mapLegacySanctions(grade, penalty)),
oauth_type: oauthType,
oauth_id: toNullableString(row.oauth_id),
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
oauth_info: jsonParameter(oauthInfo),
picture: toNullableString(row.PICTURE) ?? 'default.jpg',
image_server: toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`),
icon_updated_at: null,
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
terms_accepted_at: null,
privacy_accepted_at: null,
kakao_verified_at: oauthType === 'KAKAO' ? migratedAt : null,
kakao_grace_started_at: migratedAt,
delete_after: toNullableDate(row.delete_after, `member.${memberNo}.delete_after`),
created_at: toDate(row.REG_DATE, `member.${memberNo}.REG_DATE`),
updated_at: migratedAt,
last_login_at: lastLoginAt,
legacy_data: jsonParameter(legacyData),
};
};
const loadLastLogins = async (source: MariaPool): Promise<Map<number, Date>> => {
const rows = await querySource(
source,
"SELECT member_no, MAX(`date`) AS last_login_at FROM member_log WHERE action_type = 'login' GROUP BY member_no"
);
return new Map(
rows.map((row) => {
const memberNo = toNumber(row.member_no, 'member_log.member_no');
return [memberNo, toDate(row.last_login_at, `member_log.${memberNo}.last_login_at`)];
})
);
};
const processMembers = async (
source: MariaPool,
target: PoolClient | null,
migratedAt: Date,
counts: Record<string, number>
): Promise<void> => {
const lastLogins = await loadLastLogins(source);
for await (const rows of paginateSource(source, 'member', 'NO', batchSize)) {
const mapped = rows.map((row) => {
const memberNo = toNumber(row.NO, 'member.NO');
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null);
});
if (target) {
await upsertRows(target, 'app_user', mapped, ['id']);
}
counts.member = (counts.member ?? 0) + mapped.length;
}
};
const processMemberLogs = async (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> => {
for await (const rows of paginateSource(source, 'member_log', 'id', batchSize)) {
const mapped = rows.map<TargetRow>((row) => {
const id = toBigInt(row.id, 'member_log.id');
const memberNo = toNumber(row.member_no, `member_log.${id}.member_no`);
return {
id: id.toString(),
member_no: memberNo,
user_id: legacyUserId(memberNo),
date: toDate(row.date, `member_log.${id}.date`),
action_type: toStringValue(row.action_type, `member_log.${id}.action_type`),
action: row.action === null ? null : jsonParameter(parseJson(row.action, `member_log.${id}.action`)),
};
});
if (target) {
await upsertRows(target, 'legacy_member_log', mapped, ['id']);
}
counts.member_log = (counts.member_log ?? 0) + mapped.length;
}
};
const processBannedMembers = async (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> => {
const rows = await querySource(source, 'SELECT * FROM banned_member ORDER BY no');
const mapped = rows.map<TargetRow>((row) => ({
no: toNumber(row.no, 'banned_member.no'),
hashed_email: toStringValue(row.hashed_email, 'banned_member.hashed_email'),
info: toNullableString(row.info),
}));
if (target) {
await upsertRows(target, 'legacy_banned_member', mapped, ['no']);
}
counts.banned_member = mapped.length;
};
const processRootKeyValues = async (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> => {
const storage = await querySource(source, 'SELECT * FROM storage ORDER BY id');
const rows: TargetRow[] = storage.map((row) => ({
source_table: 'storage',
namespace: toStringValue(row.namespace, 'storage.namespace'),
key: toStringValue(row.key, 'storage.key'),
value: jsonParameter(parseJson(row.value, `storage.${String(row.id)}.value`)),
}));
if (target) {
for (let offset = 0; offset < rows.length; offset += batchSize) {
await upsertRows(target, 'legacy_root_key_value', rows.slice(offset, offset + batchSize), [
'source_table',
'namespace',
'key',
]);
}
}
counts.root_key_value = rows.length;
};
const processSystem = async (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> => {
const rows = await querySource(source, 'SELECT * FROM system ORDER BY NO');
const mapped = rows.map<TargetRow>((row) => ({
no: toNumber(row.NO, 'system.NO'),
registration_enabled: row.REG === 'Y',
login_enabled: row.LOGIN === 'Y',
notice: toNullableString(row.NOTICE) ?? '',
created_at: toNullableDate(row.CRT_DATE, 'system.CRT_DATE'),
updated_at: toNullableDate(row.MDF_DATE, 'system.MDF_DATE'),
}));
if (target) {
await upsertRows(target, 'system', mapped, ['no']);
}
counts.system = mapped.length;
};
export const migrateGateway = async (
source: MariaPool,
targetPool: PgPool | null,
apply: boolean,
migratedAt: Date
): Promise<MigrationSummary> => {
const counts: Record<string, number> = {};
const excluded = {
login_token:
'Legacy bearer tokens, IP addresses, and expired sessions are not valid in the Redis session model.',
};
const client = apply && targetPool ? await targetPool.connect() : null;
try {
const run = async (): Promise<void> => {
await processMembers(source, client, migratedAt, counts);
await processMemberLogs(source, client, counts);
await processBannedMembers(source, client, counts);
await processRootKeyValues(source, client, counts);
await processSystem(source, client, counts);
};
if (client) {
await withMigrationLock(client, 'sammo-legacy-gateway-v1', run);
} else {
await run();
}
} finally {
client?.release();
}
return { command: 'gateway', apply, counts, excluded };
};
+15
View File
@@ -0,0 +1,15 @@
import { createHash } from 'node:crypto';
const LEGACY_USER_NAMESPACE = 'sammo-ts:legacy-root-member:v1';
export const legacyUserId = (memberNo: number): string => {
if (!Number.isSafeInteger(memberNo) || memberNo <= 0) {
throw new Error(`Legacy member number must be a positive safe integer: ${memberNo}`);
}
const bytes = createHash('sha256').update(`${LEGACY_USER_NAMESPACE}:${memberNo}`).digest().subarray(0, 16);
bytes[6] = (bytes[6]! & 0x0f) | 0x50;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = bytes.toString('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
};
+35
View File
@@ -0,0 +1,35 @@
import { argon2, randomBytes } from 'node:crypto';
const MEMORY_KIB = 19 * 1024;
const PASSES = 2;
const PARALLELISM = 1;
const TAG_LENGTH = 32;
const PREFIX = `$argon2id$v=19$m=${MEMORY_KIB},t=${PASSES},p=${PARALLELISM}$`;
export const hashPasswordForReset = async (password: string): Promise<{ hash: string; salt: string }> => {
const salt = randomBytes(16);
const derived = await new Promise<Buffer>((resolve, reject) => {
argon2(
'argon2id',
{
message: Buffer.from(password, 'utf8'),
nonce: salt,
parallelism: PARALLELISM,
tagLength: TAG_LENGTH,
memory: MEMORY_KIB,
passes: PASSES,
},
(error, result) => {
if (error) {
reject(error);
return;
}
resolve(result);
}
);
});
return {
hash: `${PREFIX}${salt.toString('base64url')}$${derived.toString('base64url')}`,
salt: '',
};
};
+115
View File
@@ -0,0 +1,115 @@
import { legacyUserId } from './identity.js';
export type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| {
[key: string]: JsonValue;
};
export const parseJson = (value: unknown, context: string): JsonValue => {
if (value === null || typeof value === 'boolean' || typeof value === 'number') {
return value;
}
if (typeof value === 'object') {
return value as JsonValue;
}
if (typeof value !== 'string') {
throw new Error(`${context}: expected JSON text`);
}
try {
return JSON.parse(value) as JsonValue;
} catch (error) {
throw new Error(`${context}: invalid JSON`, { cause: error });
}
};
const asObject = (value: JsonValue): Record<string, JsonValue> =>
value !== null && !Array.isArray(value) && typeof value === 'object' ? value : {};
const asStringArray = (value: JsonValue | undefined): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
const legacyAclRoleMap: Record<string, string> = {
openClose: 'admin.profiles.manage',
reset: 'admin.reset.schedule',
update: 'admin.profiles.manage',
fullUpdate: 'admin.profiles.manage',
vote: 'admin.survey.open',
globalNotice: 'admin.notice.manage',
notice: 'admin.notice.manage',
blockGeneral: 'admin.users.manage',
};
export const mapLegacyRoles = (grade: number, rawAcl: JsonValue): string[] => {
const roles = new Set<string>(['user']);
if (grade >= 7) {
roles.add('superuser');
} else if (grade === 6) {
roles.add('admin.profiles.manage');
roles.add('admin.notice.manage');
roles.add('admin.reset.schedule');
roles.add('admin.resume.when-stopped');
} else if (grade === 5) {
roles.add('admin.users.manage');
roles.add('admin.users.create');
}
for (const [profile, permissions] of Object.entries(asObject(rawAcl))) {
for (const permission of asStringArray(permissions)) {
const mapped = legacyAclRoleMap[permission];
if (mapped) {
roles.add(`${mapped}:${profile}:default`);
} else {
roles.add(`legacy.acl.${permission}:${profile}`);
}
}
}
return [...roles].sort();
};
export const mapLegacySanctions = (grade: number, penalty: JsonValue): JsonValue => {
const flags = grade === 0 ? ['legacy-blocked'] : [];
return {
...(flags.length ? { flags, suspendedUntil: '9999-12-31T23:59:59.999Z' } : {}),
legacyPenalty: penalty,
};
};
export const classifyGameStorage = (
namespace: string,
key: string
): 'persistent-projected' | 'persistent-archive' | 'season-state' => {
if (/^inheritance_\d+$/.test(namespace)) {
return 'persistent-projected';
}
if (/^user_\d+$/.test(namespace)) {
return key === 'last_stat_reset' ? 'persistent-projected' : 'persistent-archive';
}
return 'season-state';
};
export const parseStorageUserId = (namespace: string, prefix: 'inheritance' | 'user'): string | null => {
const match = new RegExp(`^${prefix}_(\\d+)$`).exec(namespace);
if (!match?.[1]) {
return null;
}
return legacyUserId(Number.parseInt(match[1], 10));
};
export const parseInheritanceValue = (value: JsonValue, context: string): number => {
const scalar = Array.isArray(value) ? value[0] : value;
if (typeof scalar === 'number' && Number.isFinite(scalar)) {
return scalar;
}
if (typeof scalar === 'string') {
const parsed = Number(scalar);
if (Number.isFinite(parsed)) {
return parsed;
}
}
throw new Error(`${context}: inheritance value must be numeric`);
};
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { legacyUserId } from '../src/identity.js';
import { classifyGameStorage, mapLegacyRoles, parseInheritanceValue, parseStorageUserId } from '../src/transform.js';
describe('legacy database transforms', () => {
it('creates stable UUID-shaped user IDs without exposing the legacy sequence', () => {
expect(legacyUserId(42)).toBe(legacyUserId(42));
expect(legacyUserId(42)).not.toBe(legacyUserId(43));
expect(legacyUserId(42)).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
it('maps legacy grades and scoped ACL without granting a scoped operator superuser', () => {
expect(mapLegacyRoles(7, {})).toContain('superuser');
expect(mapLegacyRoles(5, {})).toEqual(['admin.users.create', 'admin.users.manage', 'user']);
expect(mapLegacyRoles(1, { che: ['reset', 'notice'] })).toEqual([
'admin.notice.manage:che:default',
'admin.reset.schedule:che:default',
'user',
]);
});
it('separates persistent storage projections from current-season state', () => {
expect(classifyGameStorage('inheritance_42', 'previous')).toBe('persistent-projected');
expect(classifyGameStorage('user_42', 'last_stat_reset')).toBe('persistent-projected');
expect(classifyGameStorage('game_env', 'year')).toBe('season-state');
expect(parseStorageUserId('inheritance_42', 'inheritance')).toBe(legacyUserId(42));
expect(parseInheritanceValue('123.5', 'fixture')).toBe(123.5);
expect(parseInheritanceValue([123.5, null], 'fixture')).toBe(123.5);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "dist",
"rootDir": ".",
"types": ["node"]
},
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
}
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.ts'],
},
});