From 89013272e15b3880b1762bdd7ccee2987e3af590 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 27 Jul 2026 01:12:00 +0000 Subject: [PATCH] feat: migrate legacy long-lived database records --- app/game-api/src/router.ts | 2 + app/game-api/src/router/archive/index.ts | 118 +++++ app/game-api/src/router/yearbook/index.ts | 37 +- app/game-api/test/archiveRouter.test.ts | 121 +++++ .../test/yearbookArchiveRouter.test.ts | 11 +- app/game-engine/src/turn/databaseHooks.ts | 4 +- .../src/turn/unificationHandler.ts | 8 +- app/game-engine/src/turn/yearbookHandler.ts | 4 +- ...BoundaryPrePersistence.integration.test.ts | 3 +- ...nthlyWanderPersistence.integration.test.ts | 2 +- app/game-frontend/e2e/pastPlays.spec.ts | 122 +++++ app/game-frontend/e2e/playwright.config.mjs | 1 + app/game-frontend/src/router/index.ts | 9 + app/game-frontend/src/views/JoinView.vue | 1 + app/game-frontend/src/views/MainView.vue | 1 + app/game-frontend/src/views/MyPageView.vue | 1 + app/game-frontend/src/views/PastPlaysView.vue | 239 ++++++++++ docs/legacy-db-migration.md | 100 ++++ package.json | 3 +- packages/infra/package.json | 1 + packages/infra/prisma.gateway.config.ts | 18 + packages/infra/prisma/game.prisma | 44 +- .../migration.sql | 125 +++++ .../migration.sql | 39 ++ packages/infra/prisma/gateway.prisma | 190 +++++--- .../migration.sql | 48 ++ pnpm-lock.yaml | 61 ++- tools/legacy-db-migration/README.md | 58 +++ tools/legacy-db-migration/package.json | 29 ++ tools/legacy-db-migration/src/cli.ts | 151 ++++++ tools/legacy-db-migration/src/db.ts | 186 ++++++++ tools/legacy-db-migration/src/game.ts | 445 ++++++++++++++++++ tools/legacy-db-migration/src/gateway.ts | 223 +++++++++ tools/legacy-db-migration/src/identity.ts | 15 + tools/legacy-db-migration/src/password.ts | 35 ++ tools/legacy-db-migration/src/transform.ts | 115 +++++ .../test/transform.test.ts | 31 ++ tools/legacy-db-migration/tsconfig.json | 10 + tools/legacy-db-migration/vitest.config.ts | 8 + 39 files changed, 2495 insertions(+), 124 deletions(-) create mode 100644 app/game-api/src/router/archive/index.ts create mode 100644 app/game-api/test/archiveRouter.test.ts create mode 100644 app/game-frontend/e2e/pastPlays.spec.ts create mode 100644 app/game-frontend/src/views/PastPlaysView.vue create mode 100644 docs/legacy-db-migration.md create mode 100644 packages/infra/prisma.gateway.config.ts create mode 100644 packages/infra/prisma/gateway-migrations/20250101000000_init_gateway_schema/migration.sql create mode 100644 packages/infra/prisma/gateway-migrations/20260727000000_add_legacy_migration_archive/migration.sql create mode 100644 packages/infra/prisma/migrations/20260727000000_add_legacy_migration_archive/migration.sql create mode 100644 tools/legacy-db-migration/README.md create mode 100644 tools/legacy-db-migration/package.json create mode 100644 tools/legacy-db-migration/src/cli.ts create mode 100644 tools/legacy-db-migration/src/db.ts create mode 100644 tools/legacy-db-migration/src/game.ts create mode 100644 tools/legacy-db-migration/src/gateway.ts create mode 100644 tools/legacy-db-migration/src/identity.ts create mode 100644 tools/legacy-db-migration/src/password.ts create mode 100644 tools/legacy-db-migration/src/transform.ts create mode 100644 tools/legacy-db-migration/test/transform.test.ts create mode 100644 tools/legacy-db-migration/tsconfig.json create mode 100644 tools/legacy-db-migration/vitest.config.ts diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index 4c76a38..cf8888a 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -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; diff --git a/app/game-api/src/router/archive/index.ts b/app/game-api/src/router/archive/index.ts new file mode 100644 index 0000000..34e465d --- /dev/null +++ b/app/game-api/src/router/archive/index.ts @@ -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(); + 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); + }), + }; + }), +}); diff --git a/app/game-api/src/router/yearbook/index.ts b/app/game-api/src/router/yearbook/index.ts index 19f4429..a98f2a0 100644 --- a/app/game-api/src/router/yearbook/index.ts +++ b/app/game-api/src/router/yearbook/index.ts @@ -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: [`●${input.month}월: 기록 없음`], - globalAction: [`●${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, diff --git a/app/game-api/test/archiveRouter.test.ts b/app/game-api/test/archiveRouter.test.ts new file mode 100644 index 0000000..8f65b93 --- /dev/null +++ b/app/game-api/test/archiveRouter.test.ts @@ -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, + }), + ], + }), + ]); + }); +}); diff --git a/app/game-api/test/yearbookArchiveRouter.test.ts b/app/game-api/test/yearbookArchiveRouter.test.ts index 39b4316..9ff6359 100644 --- a/app/game-api/test/yearbookArchiveRouter.test.ts +++ b/app/game-api/test/yearbookArchiveRouter.test.ts @@ -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: ['●1월: 기록 없음'], + globalAction: ['●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: ['●2월: 기록 없음'], + globalAction: ['●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 } }) => diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 2ec788a..bcc2fcc 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -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, diff --git a/app/game-engine/src/turn/unificationHandler.ts b/app/game-engine/src/turn/unificationHandler.ts index 85a6afb..490c7ad 100644 --- a/app/game-engine/src/turn/unificationHandler.ts +++ b/app/game-engine/src/turn/unificationHandler.ts @@ -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: '재야', diff --git a/app/game-engine/src/turn/yearbookHandler.ts b/app/game-engine/src/turn/yearbookHandler.ts index 74925e0..b0c303c 100644 --- a/app/game-engine/src/turn/yearbookHandler.ts +++ b/app/game-engine/src/turn/yearbookHandler.ts @@ -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, diff --git a/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts b/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts index 5e9210c..6811b9e 100644 --- a/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts +++ b/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts @@ -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, }, }, }); diff --git a/app/game-engine/test/monthlyWanderPersistence.integration.test.ts b/app/game-engine/test/monthlyWanderPersistence.integration.test.ts index c8bd51f..928410d 100644 --- a/app/game-engine/test/monthlyWanderPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyWanderPersistence.integration.test.ts @@ -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], diff --git a/app/game-frontend/e2e/pastPlays.spec.ts b/app/game-frontend/e2e/pastPlays.spec.ts new file mode 100644 index 0000000..e75172d --- /dev/null +++ b/app/game-frontend/e2e/pastPlays.spec.ts @@ -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'); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 9abbff6..31ce453 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -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', diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index 22aae5d..3feaa10 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.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', diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index 6f7feb1..6be150c 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -281,6 +281,7 @@ onMounted(() => {

로그인 완료, 아직 장수가 없는 상태입니다.

+ 내 지난 플레이 전투 시뮬레이터 diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index bdb24bd..6ad2fe5 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -151,6 +151,7 @@ watch( 게시판 전투 시뮬레이터 내 정보&설정 + 내 지난 플레이 토너먼트 diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index 102a996..66e4e0a 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -223,6 +223,7 @@ onMounted(() => {
내 정 보 + 지난 플레이 돌아가기
diff --git a/app/game-frontend/src/views/PastPlaysView.vue b/app/game-frontend/src/views/PastPlaysView.vue new file mode 100644 index 0000000..57f22a6 --- /dev/null +++ b/app/game-frontend/src/views/PastPlaysView.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/docs/legacy-db-migration.md b/docs/legacy-db-migration.md new file mode 100644 index 0000000..3cc072e --- /dev/null +++ b/docs/legacy-db-migration.md @@ -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. diff --git a/package.json b/package.json index 973656e..bbe2aaa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/infra/package.json b/packages/infra/package.json index 204493e..a16cde7 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -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" diff --git a/packages/infra/prisma.gateway.config.ts b/packages/infra/prisma.gateway.config.ts new file mode 100644 index 0000000..983ebe3 --- /dev/null +++ b/packages/infra/prisma.gateway.config.ts @@ -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, + }, +}); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 3622302..e4b3e4f 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -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") diff --git a/packages/infra/prisma/gateway-migrations/20250101000000_init_gateway_schema/migration.sql b/packages/infra/prisma/gateway-migrations/20250101000000_init_gateway_schema/migration.sql new file mode 100644 index 0000000..67e2756 --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20250101000000_init_gateway_schema/migration.sql @@ -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 '' +); diff --git a/packages/infra/prisma/gateway-migrations/20260727000000_add_legacy_migration_archive/migration.sql b/packages/infra/prisma/gateway-migrations/20260727000000_add_legacy_migration_archive/migration.sql new file mode 100644 index 0000000..7346c1a --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260727000000_add_legacy_migration_archive/migration.sql @@ -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") +); diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index 29a44e3..1075085 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -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") } diff --git a/packages/infra/prisma/migrations/20260727000000_add_legacy_migration_archive/migration.sql b/packages/infra/prisma/migrations/20260727000000_add_legacy_migration_archive/migration.sql new file mode 100644 index 0000000..331b964 --- /dev/null +++ b/packages/infra/prisma/migrations/20260727000000_add_legacy_migration_archive/migration.sql @@ -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"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac0e8ac..58c64c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/tools/legacy-db-migration/README.md b/tools/legacy-db-migration/README.md new file mode 100644 index 0000000..9cdec22 --- /dev/null +++ b/tools/legacy-db-migration/README.md @@ -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. diff --git a/tools/legacy-db-migration/package.json b/tools/legacy-db-migration/package.json new file mode 100644 index 0000000..d53f2e5 --- /dev/null +++ b/tools/legacy-db-migration/package.json @@ -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" + } +} diff --git a/tools/legacy-db-migration/src/cli.ts b/tools/legacy-db-migration/src/cli.ts new file mode 100644 index 0000000..85ea39a --- /dev/null +++ b/tools/legacy-db-migration/src/cli.ts @@ -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 [--apply] + pnpm --filter @sammo-ts/legacy-db-migration migrate reset-password --login-id --password-file --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> => { + 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 => { + 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; +}); diff --git a/tools/legacy-db-migration/src/db.ts b/tools/legacy-db-migration/src/db.ts new file mode 100644 index 0000000..abbd151 --- /dev/null +++ b/tools/legacy-db-migration/src/db.ts @@ -0,0 +1,186 @@ +import mariadb, { type Pool as MariaPool } from 'mariadb'; +import pg, { type PoolClient } from 'pg'; + +export type SourceRow = Record; +export type TargetRow = Record; + +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 => { + 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 { + 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 => { + 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 ( + client: PoolClient, + lockName: string, + operation: () => Promise +): Promise => { + 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); diff --git a/tools/legacy-db-migration/src/game.ts b/tools/legacy-db-migration/src/game.ts new file mode 100644 index 0000000..2d5b27c --- /dev/null +++ b/tools/legacy-db-migration/src/game.ts @@ -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, + size = batchSize +): Promise => { + 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): Promise => + 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): Promise => + 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 +): Promise => + 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 +): Promise => + 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): Promise => + 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 +): Promise => + 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 +): Promise => + 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 +): Promise => { + 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 +): Promise => { + 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 => { + const counts: Record = {}; + 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 => { + 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 }; +}; diff --git a/tools/legacy-db-migration/src/gateway.ts b/tools/legacy-db-migration/src/gateway.ts new file mode 100644 index 0000000..14b9cc9 --- /dev/null +++ b/tools/legacy-db-migration/src/gateway.ts @@ -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; + excluded: Record; +} + +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> => { + 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 +): Promise => { + 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 +): Promise => { + for await (const rows of paginateSource(source, 'member_log', 'id', batchSize)) { + const mapped = rows.map((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 +): Promise => { + const rows = await querySource(source, 'SELECT * FROM banned_member ORDER BY no'); + const mapped = rows.map((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 +): Promise => { + 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 +): Promise => { + const rows = await querySource(source, 'SELECT * FROM system ORDER BY NO'); + const mapped = rows.map((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 => { + const counts: Record = {}; + 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 => { + 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 }; +}; diff --git a/tools/legacy-db-migration/src/identity.ts b/tools/legacy-db-migration/src/identity.ts new file mode 100644 index 0000000..4aee0f3 --- /dev/null +++ b/tools/legacy-db-migration/src/identity.ts @@ -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)}`; +}; diff --git a/tools/legacy-db-migration/src/password.ts b/tools/legacy-db-migration/src/password.ts new file mode 100644 index 0000000..218672b --- /dev/null +++ b/tools/legacy-db-migration/src/password.ts @@ -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((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: '', + }; +}; diff --git a/tools/legacy-db-migration/src/transform.ts b/tools/legacy-db-migration/src/transform.ts new file mode 100644 index 0000000..bf3cef2 --- /dev/null +++ b/tools/legacy-db-migration/src/transform.ts @@ -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 => + 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 = { + 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(['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`); +}; diff --git a/tools/legacy-db-migration/test/transform.test.ts b/tools/legacy-db-migration/test/transform.test.ts new file mode 100644 index 0000000..6707e0c --- /dev/null +++ b/tools/legacy-db-migration/test/transform.test.ts @@ -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); + }); +}); diff --git a/tools/legacy-db-migration/tsconfig.json b/tools/legacy-db-migration/tsconfig.json new file mode 100644 index 0000000..3f46c8d --- /dev/null +++ b/tools/legacy-db-migration/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] +} diff --git a/tools/legacy-db-migration/vitest.config.ts b/tools/legacy-db-migration/vitest.config.ts new file mode 100644 index 0000000..1cd9eb7 --- /dev/null +++ b/tools/legacy-db-migration/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + }, +});