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 } }) =>
|
||||
|
||||
@@ -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: '재야',
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">내 정보&설정</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>
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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")
|
||||
@@ -438,16 +440,19 @@ model DiplomacyLetter {
|
||||
}
|
||||
|
||||
model YearbookHistory {
|
||||
id Int @id @default(autoincrement())
|
||||
profileName String @map("profile_name")
|
||||
year Int
|
||||
month Int
|
||||
map Json
|
||||
nations Json
|
||||
hash String @default("")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
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")
|
||||
|
||||
+125
@@ -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 ''
|
||||
);
|
||||
+39
@@ -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")
|
||||
);
|
||||
@@ -32,106 +32,144 @@ enum GatewayBuildStatus {
|
||||
}
|
||||
|
||||
enum GatewayOperationType {
|
||||
RESET
|
||||
START
|
||||
STOP
|
||||
RESET
|
||||
START
|
||||
STOP
|
||||
}
|
||||
|
||||
enum GatewayOperationStatus {
|
||||
QUEUED
|
||||
RUNNING
|
||||
SUCCEEDED
|
||||
FAILED
|
||||
CANCELLED
|
||||
QUEUED
|
||||
RUNNING
|
||||
SUCCEEDED
|
||||
FAILED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum GatewaySourceMode {
|
||||
BRANCH
|
||||
COMMIT
|
||||
BRANCH
|
||||
COMMIT
|
||||
}
|
||||
|
||||
model AppUser {
|
||||
id String @id @default(uuid())
|
||||
loginId String @unique @map("login_id")
|
||||
displayName String @unique @map("display_name")
|
||||
passwordHash String @map("password_hash")
|
||||
passwordSalt String @map("password_salt")
|
||||
roles Json @default(dbgenerated("'[]'::jsonb"))
|
||||
sanctions Json @default(dbgenerated("'{}'::jsonb"))
|
||||
oauthType OAuthType @default(NONE) @map("oauth_type")
|
||||
oauthId String? @unique @map("oauth_id")
|
||||
email String? @unique
|
||||
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
|
||||
picture String @default("default.jpg")
|
||||
imageServer Int @default(0) @map("image_server")
|
||||
iconUpdatedAt DateTime? @map("icon_updated_at")
|
||||
thirdPartyUse Boolean @default(true) @map("third_party_use")
|
||||
termsAcceptedAt DateTime? @map("terms_accepted_at")
|
||||
privacyAcceptedAt DateTime? @map("privacy_accepted_at")
|
||||
kakaoVerifiedAt DateTime? @map("kakao_verified_at")
|
||||
kakaoGraceStartedAt DateTime @default(now()) @map("kakao_grace_started_at")
|
||||
deleteAfter DateTime? @map("delete_after")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
id String @id @default(uuid())
|
||||
loginId String @unique @map("login_id")
|
||||
displayName String @unique @map("display_name")
|
||||
passwordHash String @map("password_hash")
|
||||
passwordSalt String @map("password_salt")
|
||||
roles Json @default(dbgenerated("'[]'::jsonb"))
|
||||
sanctions Json @default(dbgenerated("'{}'::jsonb"))
|
||||
oauthType OAuthType @default(NONE) @map("oauth_type")
|
||||
oauthId String? @unique @map("oauth_id")
|
||||
email String? @unique
|
||||
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
|
||||
picture String @default("default.jpg")
|
||||
imageServer Int @default(0) @map("image_server")
|
||||
iconUpdatedAt DateTime? @map("icon_updated_at")
|
||||
thirdPartyUse Boolean @default(true) @map("third_party_use")
|
||||
termsAcceptedAt DateTime? @map("terms_accepted_at")
|
||||
privacyAcceptedAt DateTime? @map("privacy_accepted_at")
|
||||
kakaoVerifiedAt DateTime? @map("kakao_verified_at")
|
||||
kakaoGraceStartedAt DateTime @default(now()) @map("kakao_grace_started_at")
|
||||
deleteAfter DateTime? @map("delete_after")
|
||||
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
|
||||
scenario String
|
||||
apiPort Int @map("api_port")
|
||||
status GatewayProfileStatus
|
||||
buildStatus GatewayBuildStatus @default(IDLE) @map("build_status")
|
||||
buildCommitSha String? @map("build_commit_sha")
|
||||
buildWorkspace String? @map("build_workspace")
|
||||
buildLastUsedAt DateTime? @map("build_last_used_at")
|
||||
preopenAt DateTime? @map("preopen_at")
|
||||
openAt DateTime? @map("open_at")
|
||||
scheduledStartAt DateTime? @map("scheduled_start_at")
|
||||
buildRequestedAt DateTime? @map("build_requested_at")
|
||||
buildStartedAt DateTime? @map("build_started_at")
|
||||
buildCompletedAt DateTime? @map("build_completed_at")
|
||||
buildError String? @map("build_error")
|
||||
lastError String? @map("last_error")
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
operations GatewayOperation[]
|
||||
profileName String @id @map("profile_name")
|
||||
profile String
|
||||
scenario String
|
||||
apiPort Int @map("api_port")
|
||||
status GatewayProfileStatus
|
||||
buildStatus GatewayBuildStatus @default(IDLE) @map("build_status")
|
||||
buildCommitSha String? @map("build_commit_sha")
|
||||
buildWorkspace String? @map("build_workspace")
|
||||
buildLastUsedAt DateTime? @map("build_last_used_at")
|
||||
preopenAt DateTime? @map("preopen_at")
|
||||
openAt DateTime? @map("open_at")
|
||||
scheduledStartAt DateTime? @map("scheduled_start_at")
|
||||
buildRequestedAt DateTime? @map("build_requested_at")
|
||||
buildStartedAt DateTime? @map("build_started_at")
|
||||
buildCompletedAt DateTime? @map("build_completed_at")
|
||||
buildError String? @map("build_error")
|
||||
lastError String? @map("last_error")
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
operations GatewayOperation[]
|
||||
|
||||
@@unique([profile, scenario])
|
||||
@@map("gateway_profile")
|
||||
}
|
||||
|
||||
model GatewayOperation {
|
||||
id String @id @default(uuid())
|
||||
profileName String @map("profile_name")
|
||||
profile GatewayProfile @relation(fields: [profileName], references: [profileName], onDelete: Cascade)
|
||||
type GatewayOperationType
|
||||
status GatewayOperationStatus @default(QUEUED)
|
||||
sourceMode GatewaySourceMode? @map("source_mode")
|
||||
sourceRef String? @map("source_ref")
|
||||
resolvedCommitSha String? @map("resolved_commit_sha")
|
||||
payload Json @default(dbgenerated("'{}'::jsonb"))
|
||||
reason String?
|
||||
requestedBy String @map("requested_by")
|
||||
scheduledAt DateTime? @map("scheduled_at")
|
||||
startedAt DateTime? @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
error String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id String @id @default(uuid())
|
||||
profileName String @map("profile_name")
|
||||
profile GatewayProfile @relation(fields: [profileName], references: [profileName], onDelete: Cascade)
|
||||
type GatewayOperationType
|
||||
status GatewayOperationStatus @default(QUEUED)
|
||||
sourceMode GatewaySourceMode? @map("source_mode")
|
||||
sourceRef String? @map("source_ref")
|
||||
resolvedCommitSha String? @map("resolved_commit_sha")
|
||||
payload Json @default(dbgenerated("'{}'::jsonb"))
|
||||
reason String?
|
||||
requestedBy String @map("requested_by")
|
||||
scheduledAt DateTime? @map("scheduled_at")
|
||||
startedAt DateTime? @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
error String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([status, scheduledAt, createdAt])
|
||||
@@index([profileName, createdAt])
|
||||
@@map("gateway_operation")
|
||||
@@index([status, scheduledAt, createdAt])
|
||||
@@index([profileName, createdAt])
|
||||
@@map("gateway_operation")
|
||||
}
|
||||
|
||||
model SystemSetting {
|
||||
id Int @id @default(1) @map("no")
|
||||
notice String @default("") @map("notice")
|
||||
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")
|
||||
}
|
||||
|
||||
+48
@@ -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");
|
||||
Generated
+53
-8
@@ -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: {}
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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)}`;
|
||||
};
|
||||
@@ -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: '',
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user