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