feat: migrate legacy long-lived database records

This commit is contained in:
2026-07-27 01:12:00 +00:00
parent d8220a18b3
commit 89013272e1
39 changed files with 2495 additions and 124 deletions
+58
View File
@@ -0,0 +1,58 @@
# Legacy DB migration CLI
This package migrates the long-lived parts of a restored ref MariaDB database
into the core2026 PostgreSQL schemas. It is CLI-only; no HTTP or administrator
route invokes it.
The default mode is a read-only dry-run. `--apply` is required before any target
write. PostgreSQL advisory locks prevent two applies for the same target. Every
write uses a stable legacy key and `ON CONFLICT`, so a completed or interrupted
run can be repeated.
## Source restore
Restore each compressed table dump into a private MariaDB database before
running this tool. Do not expose that database on a public interface. The dump
directory is intentionally Git-ignored.
```sh
gzip -cd /path/to/db_dumps/root/member.sql.gz | mariadb root_dump
gzip -cd /path/to/db_dumps/che/ng_games.sql.gz | mariadb che_dump
```
Restore all tables defined by ref even though the CLI intentionally projects
only long-lived tables. This lets the dry-run verify the source inventory and
keeps the original dump as the recovery source.
Database URLs belong in a Git-ignored environment file or injected process
environment. They are deliberately not accepted as command-line flags.
## Commands
```sh
LEGACY_ROOT_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate gateway
LEGACY_GAME_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate game --profile che
```
After reviewing the JSON counts and excluded-table reasons, add
`GATEWAY_DATABASE_URL` or `GAME_DATABASE_URL` and repeat with `--apply`.
Kakao members retain their OAuth ID, email, and OAuth metadata.
`kakao_verified_at` and `kakao_grace_started_at` are set to the migration time,
which renews verification at cutover. Legacy password hashes and salts are
retained and upgraded to Argon2id after the first successful login when
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is configured in gateway-api.
Only tables present in the checked ref schemas are eligible. Extra tables found
in a dump, such as an old root `config` table, are left in the recovery dump and
are not silently imported.
For an isolated test account, put a temporary password in a mode-0600 file and
run:
```sh
GATEWAY_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate \
reset-password --login-id test-user --password-file /secure/path/password --apply
```
The password value is never accepted on the command line or printed.
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@sammo-ts/legacy-db-migration",
"private": true,
"version": "0.0.0",
"type": "module",
"bin": {
"sammo-migrate-legacy-db": "./dist/cli.mjs"
},
"scripts": {
"build": "tsdown src/cli.ts --no-config --format esm --out-dir dist --dts --sourcemap --target node22 --platform node",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc -b",
"migrate": "tsx src/cli.ts"
},
"dependencies": {
"mariadb": "3.5.3",
"pg": "^8.16.3"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/pg": "^8.15.6",
"tsdown": "^0.22.14",
"tsx": "^4.20.6",
"typescript": "6.0.2",
"vitest": "^4.0.16"
}
}
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env node
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { createMariaPool, createPostgresPool } from './db.js';
import { migrateGame } from './game.js';
import { migrateGateway } from './gateway.js';
import { hashPasswordForReset } from './password.js';
type Command = 'gateway' | 'game' | 'reset-password';
interface CliOptions {
command: Command;
apply: boolean;
profile?: string;
loginId?: string;
passwordFile?: string;
}
const usage = `Usage:
pnpm --filter @sammo-ts/legacy-db-migration migrate gateway [--apply]
pnpm --filter @sammo-ts/legacy-db-migration migrate game --profile <profile> [--apply]
pnpm --filter @sammo-ts/legacy-db-migration migrate reset-password --login-id <id> --password-file <path> --apply
Environment:
LEGACY_ROOT_DATABASE_URL MariaDB URL for the restored root dump
LEGACY_GAME_DATABASE_URL MariaDB URL for one restored game-profile dump
GATEWAY_DATABASE_URL target PostgreSQL URL for gateway
GAME_DATABASE_URL target PostgreSQL URL for the selected game profile
Dry-run is the default. Source and target URLs are accepted only through the environment so
credentials are not exposed in the process list.`;
const parseArguments = (argv: readonly string[]): CliOptions => {
const command = argv[0];
if (command !== 'gateway' && command !== 'game' && command !== 'reset-password') {
throw new Error(usage);
}
const options: CliOptions = { command, apply: false };
for (let index = 1; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--apply') {
options.apply = true;
continue;
}
const next = argv[index + 1];
if (!next || next.startsWith('--')) {
throw new Error(`Missing value for ${argument}\n\n${usage}`);
}
if (argument === '--profile') {
options.profile = next;
} else if (argument === '--login-id') {
options.loginId = next;
} else if (argument === '--password-file') {
options.passwordFile = next;
} else {
throw new Error(`Unknown argument: ${argument}\n\n${usage}`);
}
index += 1;
}
return options;
};
const requireEnvironment = (name: string): string => {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} is required`);
}
return value;
};
const resetPassword = async (options: CliOptions): Promise<Record<string, unknown>> => {
if (!options.apply) {
throw new Error('reset-password requires --apply');
}
if (!options.loginId || !options.passwordFile) {
throw new Error(`reset-password requires --login-id and --password-file\n\n${usage}`);
}
const passwordPath = path.resolve(options.passwordFile);
const passwordStat = await stat(passwordPath);
if ((passwordStat.mode & 0o077) !== 0) {
throw new Error('Password file must not be readable or writable by group/other (expected mode 0600)');
}
const password = (await readFile(passwordPath, 'utf8')).replace(/\r?\n$/, '');
if (!password) {
throw new Error('Password file is empty');
}
const pool = createPostgresPool(requireEnvironment('GATEWAY_DATABASE_URL'));
try {
const existing = await pool.query<{ id: string }>('SELECT "id" FROM "app_user" WHERE "login_id" = $1', [
options.loginId.toLowerCase(),
]);
if (existing.rowCount !== 1) {
throw new Error('Exactly one migrated account must match --login-id');
}
const hashed = await hashPasswordForReset(password);
await pool.query(
`UPDATE "app_user"
SET "password_hash" = $1, "password_salt" = $2, "updated_at" = CURRENT_TIMESTAMP
WHERE "id" = $3`,
[hashed.hash, hashed.salt, existing.rows[0]!.id]
);
return { command: 'reset-password', updated: 1, loginId: options.loginId.toLowerCase() };
} finally {
await pool.end();
}
};
const run = async (): Promise<void> => {
const options = parseArguments(process.argv.slice(2));
if (options.command === 'reset-password') {
console.log(JSON.stringify(await resetPassword(options), null, 2));
return;
}
const migratedAt = new Date();
if (options.command === 'gateway') {
const source = createMariaPool(requireEnvironment('LEGACY_ROOT_DATABASE_URL'));
const target = options.apply ? createPostgresPool(requireEnvironment('GATEWAY_DATABASE_URL')) : null;
try {
const summary = await migrateGateway(source, target, options.apply, migratedAt);
console.log(JSON.stringify(summary, null, 2));
} finally {
await source.end();
await target?.end();
}
return;
}
if (!options.profile || !/^[a-z][a-z0-9_-]{1,31}$/.test(options.profile)) {
throw new Error(`game requires a safe --profile value\n\n${usage}`);
}
const source = createMariaPool(requireEnvironment('LEGACY_GAME_DATABASE_URL'));
const target = options.apply ? createPostgresPool(requireEnvironment('GAME_DATABASE_URL')) : null;
try {
const summary = await migrateGame(source, target, options.apply, options.profile);
console.log(JSON.stringify(summary, null, 2));
} finally {
await source.end();
await target?.end();
}
};
run().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`[legacy-db-migration] ${message}`);
process.exitCode = 1;
});
+186
View File
@@ -0,0 +1,186 @@
import mariadb, { type Pool as MariaPool } from 'mariadb';
import pg, { type PoolClient } from 'pg';
export type SourceRow = Record<string, unknown>;
export type TargetRow = Record<string, unknown>;
class JsonParameter {
readonly value: unknown;
constructor(value: unknown) {
this.value = value;
}
}
export const jsonParameter = (value: unknown): JsonParameter => new JsonParameter(value);
const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
const MARIA_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
const quoteIdentifier = (value: string): string => {
if (!IDENTIFIER.test(value)) {
throw new Error(`Unsafe SQL identifier: ${value}`);
}
return `"${value}"`;
};
export const createMariaPool = (uri: string): MariaPool => mariadb.createPool(uri);
export const createPostgresPool = (connectionString: string): pg.Pool =>
new pg.Pool({
connectionString,
max: 2,
application_name: 'sammo-legacy-db-migration',
});
const isSourceRow = (value: unknown): value is SourceRow =>
value !== null && !Array.isArray(value) && typeof value === 'object';
export const querySource = async (
pool: MariaPool,
sql: string,
parameters: readonly unknown[] = []
): Promise<SourceRow[]> => {
const result: unknown = await pool.query(sql, [...parameters]);
if (!Array.isArray(result)) {
throw new Error('MariaDB query did not return rows');
}
return result.filter(isSourceRow);
};
export const paginateSource = async function* (
pool: MariaPool,
table: string,
idColumn: string,
batchSize: number
): AsyncGenerator<SourceRow[]> {
if (!MARIA_IDENTIFIER.test(table) || !MARIA_IDENTIFIER.test(idColumn)) {
throw new Error('Unsafe MariaDB table or ID column');
}
let lastId = -1n;
for (;;) {
const rows = await querySource(
pool,
`SELECT * FROM \`${table}\` WHERE \`${idColumn}\` > ? ORDER BY \`${idColumn}\` ASC LIMIT ?`,
[lastId.toString(), batchSize]
);
if (rows.length === 0) {
return;
}
yield rows;
lastId = toBigInt(rows.at(-1)?.[idColumn], `${table}.${idColumn}`);
}
};
const targetValue = (value: unknown): unknown => {
if (value instanceof JsonParameter) {
return JSON.stringify(value.value);
}
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
return JSON.stringify(value);
}
return value;
};
export const upsertRows = async (
client: PoolClient,
table: string,
rows: readonly TargetRow[],
conflictColumns: readonly string[]
): Promise<void> => {
if (rows.length === 0) {
return;
}
const columns = Object.keys(rows[0]!);
if (
columns.length === 0 ||
rows.some((row) => columns.some((column) => !(column in row))) ||
conflictColumns.some((column) => !columns.includes(column))
) {
throw new Error(`Inconsistent row shape for ${table}`);
}
const values: unknown[] = [];
const tuples = rows.map((row, rowIndex) => {
const placeholders = columns.map((column, columnIndex) => {
values.push(targetValue(row[column]));
return `$${rowIndex * columns.length + columnIndex + 1}`;
});
return `(${placeholders.join(', ')})`;
});
const updates = columns
.filter((column) => !conflictColumns.includes(column))
.map((column) => `${quoteIdentifier(column)} = EXCLUDED.${quoteIdentifier(column)}`);
const conflictAction = updates.length ? `DO UPDATE SET ${updates.join(', ')}` : 'DO NOTHING';
await client.query(
`INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')})
VALUES ${tuples.join(', ')}
ON CONFLICT (${conflictColumns.map(quoteIdentifier).join(', ')}) ${conflictAction}`,
values
);
};
export const withMigrationLock = async <T>(
client: PoolClient,
lockName: string,
operation: () => Promise<T>
): Promise<T> => {
await client.query('SELECT pg_advisory_lock(hashtext($1))', [lockName]);
try {
return await operation();
} finally {
await client.query('SELECT pg_advisory_unlock(hashtext($1))', [lockName]);
}
};
export const toNumber = (value: unknown, context: string): number => {
const number = typeof value === 'number' ? value : typeof value === 'bigint' ? Number(value) : Number(value);
if (!Number.isSafeInteger(number)) {
throw new Error(`${context}: expected a safe integer`);
}
return number;
};
export const toBigInt = (value: unknown, context: string): bigint => {
try {
return BigInt(String(value));
} catch (error) {
throw new Error(`${context}: expected an integer`, { cause: error });
}
};
export const toFloat = (value: unknown, context: string): number => {
const number = Number(value);
if (!Number.isFinite(number)) {
throw new Error(`${context}: expected a finite number`);
}
return number;
};
export const toStringValue = (value: unknown, context: string): string => {
if (typeof value !== 'string') {
throw new Error(`${context}: expected text`);
}
return value;
};
export const toNullableString = (value: unknown): string | null =>
value === null || value === undefined ? null : String(value);
export const toDate = (value: unknown, context: string): Date => {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value;
}
const text = String(value);
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(text)
? `${text.replace(' ', 'T')}Z`
: text;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
throw new Error(`${context}: invalid date`);
}
return date;
};
export const toNullableDate = (value: unknown, context: string): Date | null =>
value === null || value === undefined ? null : toDate(value, context);
+445
View File
@@ -0,0 +1,445 @@
import { createHash } from 'node:crypto';
import type { Pool as MariaPool } from 'mariadb';
import type { Pool as PgPool, PoolClient } from 'pg';
import {
paginateSource,
jsonParameter,
toDate,
toFloat,
toNullableDate,
toNullableString,
toNumber,
toStringValue,
upsertRows,
withMigrationLock,
type SourceRow,
type TargetRow,
} from './db.js';
import type { MigrationSummary } from './gateway.js';
import { legacyUserId } from './identity.js';
import {
classifyGameStorage,
parseInheritanceValue,
parseJson,
parseStorageUserId,
type JsonValue,
} from './transform.js';
const batchSize = 250;
const parseNullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue =>
value === null || value === undefined ? fallback : parseJson(value, context);
const parseJsonOrLegacyEmpty = (value: unknown, context: string): JsonValue =>
value === '' ? '' : parseJson(value, context);
const ownerId = (value: unknown): string | null => {
if (value === null || value === undefined) {
return null;
}
const memberNo = toNumber(value, 'legacy owner');
return memberNo > 0 ? legacyUserId(memberNo) : null;
};
const hashYearbook = (row: TargetRow): string =>
createHash('sha256')
.update(
JSON.stringify({
map: row.map,
nations: row.nations,
globalHistory: row.global_history,
globalAction: row.global_action,
})
)
.digest('hex');
const migrateSimpleTable = async (
source: MariaPool,
target: PoolClient | null,
sourceTable: string,
sourceIdColumn: string,
targetTable: string,
conflictColumns: readonly string[],
mapper: (row: SourceRow) => TargetRow,
counts: Record<string, number>,
size = batchSize
): Promise<void> => {
for await (const rows of paginateSource(source, sourceTable, sourceIdColumn, size)) {
const mapped = rows.map(mapper);
if (target) {
await upsertRows(target, targetTable, mapped, conflictColumns);
}
counts[sourceTable] = (counts[sourceTable] ?? 0) + mapped.length;
}
};
const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
migrateSimpleTable(
source,
target,
'hall',
'id',
'hall',
['server_id', 'type', 'general_no'],
(row) => {
const sourceId = toNumber(row.id, 'hall.id');
return {
server_id: toStringValue(row.server_id, `hall.${sourceId}.server_id`),
season: toNumber(row.season, `hall.${sourceId}.season`),
scenario: toNumber(row.scenario, `hall.${sourceId}.scenario`),
general_no: toNumber(row.general_no, `hall.${sourceId}.general_no`),
type: toStringValue(row.type, `hall.${sourceId}.type`),
value: toFloat(row.value, `hall.${sourceId}.value`),
owner: ownerId(row.owner),
aux: parseJson(row.aux, `hall.${sourceId}.aux`),
};
},
counts
);
const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
migrateSimpleTable(
source,
target,
'ng_games',
'id',
'ng_games',
['server_id'],
(row) => {
const sourceId = toNumber(row.id, 'ng_games.id');
return {
server_id: toStringValue(row.server_id, `ng_games.${sourceId}.server_id`),
date: toDate(row.date, `ng_games.${sourceId}.date`),
winner_nation:
row.winner_nation === null
? null
: toNumber(row.winner_nation, `ng_games.${sourceId}.winner_nation`),
map: toNullableString(row.map),
season: toNumber(row.season, `ng_games.${sourceId}.season`),
scenario: toNumber(row.scenario, `ng_games.${sourceId}.scenario`),
scenario_name: toStringValue(row.scenario_name, `ng_games.${sourceId}.scenario_name`),
env: parseJson(row.env, `ng_games.${sourceId}.env`),
};
},
counts
);
const migrateOldGenerals = (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> =>
migrateSimpleTable(
source,
target,
'ng_old_generals',
'id',
'ng_old_generals',
['server_id', 'general_no'],
(row) => {
const sourceId = toNumber(row.id, 'ng_old_generals.id');
return {
server_id: toStringValue(row.server_id, `ng_old_generals.${sourceId}.server_id`),
general_no: toNumber(row.general_no, `ng_old_generals.${sourceId}.general_no`),
owner: ownerId(row.owner),
name: toStringValue(row.name, `ng_old_generals.${sourceId}.name`),
last_yearmonth: toNumber(row.last_yearmonth, `ng_old_generals.${sourceId}.last_yearmonth`),
turntime: toDate(row.turntime, `ng_old_generals.${sourceId}.turntime`),
data: parseJson(row.data, `ng_old_generals.${sourceId}.data`),
};
},
counts
);
const migrateOldNations = (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> =>
migrateSimpleTable(
source,
target,
'ng_old_nations',
'id',
'ng_old_nations',
['server_id', 'nation', 'source_id'],
(row) => {
const sourceId = toNumber(row.id, 'ng_old_nations.id');
return {
server_id: toStringValue(row.server_id, `ng_old_nations.${sourceId}.server_id`),
nation: toNumber(row.nation, `ng_old_nations.${sourceId}.nation`),
source_id: sourceId,
data: parseJson(row.data, `ng_old_nations.${sourceId}.data`),
date: toDate(row.date, `ng_old_nations.${sourceId}.date`),
};
},
counts
);
const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
migrateSimpleTable(
source,
target,
'emperior',
'no',
'emperior',
['legacy_id'],
(row) => {
const id = toNumber(row.no, 'emperior.no');
return {
legacy_id: id,
server_id: toNullableString(row.server_id),
phase: toNullableString(row.phase),
nation_count: toNullableString(row.nation_count),
nation_name: toNullableString(row.nation_name),
nation_hist: toNullableString(row.nation_hist),
gen_count: toNullableString(row.gen_count),
personal_hist: toNullableString(row.personal_hist),
special_hist: toNullableString(row.special_hist),
name: toNullableString(row.name),
type: toNullableString(row.type),
color: toNullableString(row.color),
year: row.year === null ? null : toNumber(row.year, `emperior.${id}.year`),
month: row.month === null ? null : toNumber(row.month, `emperior.${id}.month`),
power: row.power === null ? null : toNumber(row.power, `emperior.${id}.power`),
gennum: row.gennum === null ? null : toNumber(row.gennum, `emperior.${id}.gennum`),
citynum: row.citynum === null ? null : toNumber(row.citynum, `emperior.${id}.citynum`),
pop: toNullableString(row.pop),
poprate: toNullableString(row.poprate),
gold: row.gold === null ? null : toNumber(row.gold, `emperior.${id}.gold`),
rice: row.rice === null ? null : toNumber(row.rice, `emperior.${id}.rice`),
l12name: toNullableString(row.l12name),
l12pic: toNullableString(row.l12pic),
l11name: toNullableString(row.l11name),
l11pic: toNullableString(row.l11pic),
l10name: toNullableString(row.l10name),
l10pic: toNullableString(row.l10pic),
l9name: toNullableString(row.l9name),
l9pic: toNullableString(row.l9pic),
l8name: toNullableString(row.l8name),
l8pic: toNullableString(row.l8pic),
l7name: toNullableString(row.l7name),
l7pic: toNullableString(row.l7pic),
l6name: toNullableString(row.l6name),
l6pic: toNullableString(row.l6pic),
l5name: toNullableString(row.l5name),
l5pic: toNullableString(row.l5pic),
tiger: toNullableString(row.tiger),
eagle: toNullableString(row.eagle),
gen: toNullableString(row.gen),
history: parseNullableJson(row.history, [], `emperior.${id}.history`),
aux: parseNullableJson(row.aux, {}, `emperior.${id}.aux`),
};
},
counts
);
const migrateInheritanceResults = (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> =>
migrateSimpleTable(
source,
target,
'inheritance_result',
'id',
'inheritance_result',
['legacy_id'],
(row) => {
const id = toNumber(row.id, 'inheritance_result.id');
return {
legacy_id: id,
server_id: toStringValue(row.server_id, `inheritance_result.${id}.server_id`),
owner: legacyUserId(toNumber(row.owner, `inheritance_result.${id}.owner`)),
general_id: toNumber(row.general_id, `inheritance_result.${id}.general_id`),
year: toNumber(row.year, `inheritance_result.${id}.year`),
month: toNumber(row.month, `inheritance_result.${id}.month`),
value: jsonParameter(parseJsonOrLegacyEmpty(row.value, `inheritance_result.${id}.value`)),
created_at: new Date(0),
};
},
counts
);
const migrateUserRecords = (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> =>
migrateSimpleTable(
source,
target,
'user_record',
'id',
'inheritance_log',
['legacy_id'],
(row) => {
const id = toNumber(row.id, 'user_record.id');
return {
legacy_id: id,
user_id: legacyUserId(toNumber(row.user_id, `user_record.${id}.user_id`)),
server_id: toStringValue(row.server_id, `user_record.${id}.server_id`),
log_type: toStringValue(row.log_type, `user_record.${id}.log_type`),
year: toNumber(row.year, `user_record.${id}.year`),
month: toNumber(row.month, `user_record.${id}.month`),
text: toStringValue(row.text, `user_record.${id}.text`),
created_at: toNullableDate(row.date, `user_record.${id}.date`) ?? new Date(0),
};
},
counts
);
const migrateYearbook = async (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> => {
await migrateSimpleTable(
source,
target,
'ng_history',
'no',
'yearbook_history',
['profile_name', 'year', 'month', 'source_id'],
(row) => {
const id = toNumber(row.no, 'ng_history.no');
const mapped: TargetRow = {
profile_name: toStringValue(row.server_id, `ng_history.${id}.server_id`),
source_id: id,
year: toNumber(row.year, `ng_history.${id}.year`),
month: toNumber(row.month, `ng_history.${id}.month`),
map: parseNullableJson(row.map, {}, `ng_history.${id}.map`),
nations: parseNullableJson(row.nations, [], `ng_history.${id}.nations`),
global_history: parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`),
global_action: parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`),
hash: '',
created_at: new Date(0),
};
mapped.hash = hashYearbook(mapped);
return mapped;
},
counts,
25
);
};
const migrateStorage = async (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> => {
for await (const rows of paginateSource(source, 'storage', 'id', batchSize)) {
const archives: TargetRow[] = [];
const points: TargetRow[] = [];
const userStates: TargetRow[] = [];
for (const row of rows) {
const sourceId = toNumber(row.id, 'storage.id');
const namespace = toStringValue(row.namespace, `storage.${sourceId}.namespace`);
const key = toStringValue(row.key, `storage.${sourceId}.key`);
const value = parseJson(row.value, `storage.${sourceId}.value`);
const scope = classifyGameStorage(namespace, key);
counts.storage_inspected = (counts.storage_inspected ?? 0) + 1;
if (scope === 'season-state') {
counts.storage_season_excluded = (counts.storage_season_excluded ?? 0) + 1;
continue;
}
archives.push({ source_id: sourceId, namespace, key, value: jsonParameter(value), scope });
const inheritanceUserId = parseStorageUserId(namespace, 'inheritance');
if (inheritanceUserId) {
points.push({
user_id: inheritanceUserId,
key,
value: parseInheritanceValue(value, `storage.${sourceId}.value`),
aux: {
legacyNamespace: namespace,
legacySourceId: sourceId,
legacyAux: Array.isArray(value) ? (value[1] ?? null) : null,
},
updated_at: new Date(0),
});
}
const stateUserId = parseStorageUserId(namespace, 'user');
if (stateUserId && key === 'last_stat_reset') {
userStates.push({
user_id: stateUserId,
meta: { lastStatReset: value, legacySourceId: sourceId },
updated_at: new Date(0),
});
}
}
if (target) {
await upsertRows(target, 'legacy_game_storage', archives, ['source_id']);
await upsertRows(target, 'inheritance_point', points, ['user_id', 'key']);
await upsertRows(target, 'inheritance_user_state', userStates, ['user_id']);
}
counts.storage_archived = (counts.storage_archived ?? 0) + archives.length;
counts.inheritance_point = (counts.inheritance_point ?? 0) + points.length;
counts.inheritance_user_state = (counts.inheritance_user_state ?? 0) + userStates.length;
}
};
export const migrateGame = async (
source: MariaPool,
targetPool: PgPool | null,
apply: boolean,
profile: string
): Promise<MigrationSummary> => {
const counts: Record<string, number> = {};
const excluded = {
general: 'Current-season actor state is intentionally not transferred.',
city: 'Current-season world state is intentionally not transferred.',
nation: 'Current-season nation state is intentionally not transferred.',
general_turn: 'Current-season command queue.',
general_access_log: 'Current-season access counters.',
nation_turn: 'Current-season nation command queue.',
nation_env: 'Current-season nation KV state.',
board: 'Current-season nation board.',
comment: 'Current-season nation board comments.',
diplomacy: 'Current-season diplomacy state.',
event: 'Current-season scheduled events.',
message: 'Current-season mailboxes.',
rank_data: 'Current-season ranking counters.',
statistic: 'Current-season statistics used to build permanent dynasty records.',
world_history: 'Current-season history; completed-month snapshots come from ng_history.',
general_record: 'Current-season general logs.',
ng_auction: 'Current-season auction.',
ng_auction_bid: 'Current-season auction bids.',
ng_betting: 'Current-season betting.',
ng_diplomacy: 'Current-season diplomacy letters.',
plock: 'Legacy process lock.',
reserved_open: 'Legacy opening schedule.',
select_npc_token: 'Ephemeral selection token.',
select_pool: 'Current-season selection pool.',
tournament: 'Current-season tournament.',
troop: 'Current-season troop state.',
vote: 'Current-season vote.',
vote_comment: 'Current-season vote comments.',
'storage:season-state': 'Only inheritance_* and user_* long-lived namespaces are archived or projected.',
};
const client = apply && targetPool ? await targetPool.connect() : null;
try {
const run = async (): Promise<void> => {
await migrateGames(source, client, counts);
await migrateHall(source, client, counts);
await migrateOldGenerals(source, client, counts);
await migrateOldNations(source, client, counts);
await migrateEmperors(source, client, counts);
await migrateInheritanceResults(source, client, counts);
await migrateUserRecords(source, client, counts);
await migrateStorage(source, client, counts);
await migrateYearbook(source, client, counts);
};
if (client) {
await withMigrationLock(client, `sammo-legacy-game-v1:${profile}`, run);
} else {
await run();
}
} finally {
client?.release();
}
return { command: 'game', apply, counts, excluded };
};
+223
View File
@@ -0,0 +1,223 @@
import type { Pool as MariaPool } from 'mariadb';
import type { Pool as PgPool, PoolClient } from 'pg';
import { legacyUserId } from './identity.js';
import {
paginateSource,
jsonParameter,
querySource,
toBigInt,
toDate,
toNullableDate,
toNullableString,
toNumber,
toStringValue,
upsertRows,
withMigrationLock,
type SourceRow,
type TargetRow,
} from './db.js';
import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js';
export interface MigrationSummary {
command: 'gateway' | 'game';
apply: boolean;
counts: Record<string, number>;
excluded: Record<string, string>;
}
const batchSize = 500;
const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => {
const memberNo = toNumber(row.NO, 'member.NO');
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
const acl = parseJson(row.acl, `member.${memberNo}.acl`);
const penalty = parseJson(row.penalty, `member.${memberNo}.penalty`);
const oauthInfo = parseJson(row.oauth_info, `member.${memberNo}.oauth_info`);
const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE';
const legacyData: JsonValue = {
memberNo,
grade,
acl,
penalty,
tokenValidUntil: toNullableString(row.token_valid_until),
regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`),
blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`),
blockDate: toNullableString(row.BLOCK_DATE),
};
return {
id: legacyUserId(memberNo),
login_id: toStringValue(row.ID, `member.${memberNo}.ID`).toLowerCase(),
display_name: toStringValue(row.NAME, `member.${memberNo}.NAME`),
password_hash: toStringValue(row.PW, `member.${memberNo}.PW`),
password_salt: toStringValue(row.salt, `member.${memberNo}.salt`),
roles: jsonParameter(mapLegacyRoles(grade, acl)),
sanctions: jsonParameter(mapLegacySanctions(grade, penalty)),
oauth_type: oauthType,
oauth_id: toNullableString(row.oauth_id),
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
oauth_info: jsonParameter(oauthInfo),
picture: toNullableString(row.PICTURE) ?? 'default.jpg',
image_server: toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`),
icon_updated_at: null,
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
terms_accepted_at: null,
privacy_accepted_at: null,
kakao_verified_at: oauthType === 'KAKAO' ? migratedAt : null,
kakao_grace_started_at: migratedAt,
delete_after: toNullableDate(row.delete_after, `member.${memberNo}.delete_after`),
created_at: toDate(row.REG_DATE, `member.${memberNo}.REG_DATE`),
updated_at: migratedAt,
last_login_at: lastLoginAt,
legacy_data: jsonParameter(legacyData),
};
};
const loadLastLogins = async (source: MariaPool): Promise<Map<number, Date>> => {
const rows = await querySource(
source,
"SELECT member_no, MAX(`date`) AS last_login_at FROM member_log WHERE action_type = 'login' GROUP BY member_no"
);
return new Map(
rows.map((row) => {
const memberNo = toNumber(row.member_no, 'member_log.member_no');
return [memberNo, toDate(row.last_login_at, `member_log.${memberNo}.last_login_at`)];
})
);
};
const processMembers = async (
source: MariaPool,
target: PoolClient | null,
migratedAt: Date,
counts: Record<string, number>
): Promise<void> => {
const lastLogins = await loadLastLogins(source);
for await (const rows of paginateSource(source, 'member', 'NO', batchSize)) {
const mapped = rows.map((row) => {
const memberNo = toNumber(row.NO, 'member.NO');
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null);
});
if (target) {
await upsertRows(target, 'app_user', mapped, ['id']);
}
counts.member = (counts.member ?? 0) + mapped.length;
}
};
const processMemberLogs = async (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> => {
for await (const rows of paginateSource(source, 'member_log', 'id', batchSize)) {
const mapped = rows.map<TargetRow>((row) => {
const id = toBigInt(row.id, 'member_log.id');
const memberNo = toNumber(row.member_no, `member_log.${id}.member_no`);
return {
id: id.toString(),
member_no: memberNo,
user_id: legacyUserId(memberNo),
date: toDate(row.date, `member_log.${id}.date`),
action_type: toStringValue(row.action_type, `member_log.${id}.action_type`),
action: row.action === null ? null : jsonParameter(parseJson(row.action, `member_log.${id}.action`)),
};
});
if (target) {
await upsertRows(target, 'legacy_member_log', mapped, ['id']);
}
counts.member_log = (counts.member_log ?? 0) + mapped.length;
}
};
const processBannedMembers = async (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> => {
const rows = await querySource(source, 'SELECT * FROM banned_member ORDER BY no');
const mapped = rows.map<TargetRow>((row) => ({
no: toNumber(row.no, 'banned_member.no'),
hashed_email: toStringValue(row.hashed_email, 'banned_member.hashed_email'),
info: toNullableString(row.info),
}));
if (target) {
await upsertRows(target, 'legacy_banned_member', mapped, ['no']);
}
counts.banned_member = mapped.length;
};
const processRootKeyValues = async (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> => {
const storage = await querySource(source, 'SELECT * FROM storage ORDER BY id');
const rows: TargetRow[] = storage.map((row) => ({
source_table: 'storage',
namespace: toStringValue(row.namespace, 'storage.namespace'),
key: toStringValue(row.key, 'storage.key'),
value: jsonParameter(parseJson(row.value, `storage.${String(row.id)}.value`)),
}));
if (target) {
for (let offset = 0; offset < rows.length; offset += batchSize) {
await upsertRows(target, 'legacy_root_key_value', rows.slice(offset, offset + batchSize), [
'source_table',
'namespace',
'key',
]);
}
}
counts.root_key_value = rows.length;
};
const processSystem = async (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>
): Promise<void> => {
const rows = await querySource(source, 'SELECT * FROM system ORDER BY NO');
const mapped = rows.map<TargetRow>((row) => ({
no: toNumber(row.NO, 'system.NO'),
registration_enabled: row.REG === 'Y',
login_enabled: row.LOGIN === 'Y',
notice: toNullableString(row.NOTICE) ?? '',
created_at: toNullableDate(row.CRT_DATE, 'system.CRT_DATE'),
updated_at: toNullableDate(row.MDF_DATE, 'system.MDF_DATE'),
}));
if (target) {
await upsertRows(target, 'system', mapped, ['no']);
}
counts.system = mapped.length;
};
export const migrateGateway = async (
source: MariaPool,
targetPool: PgPool | null,
apply: boolean,
migratedAt: Date
): Promise<MigrationSummary> => {
const counts: Record<string, number> = {};
const excluded = {
login_token:
'Legacy bearer tokens, IP addresses, and expired sessions are not valid in the Redis session model.',
};
const client = apply && targetPool ? await targetPool.connect() : null;
try {
const run = async (): Promise<void> => {
await processMembers(source, client, migratedAt, counts);
await processMemberLogs(source, client, counts);
await processBannedMembers(source, client, counts);
await processRootKeyValues(source, client, counts);
await processSystem(source, client, counts);
};
if (client) {
await withMigrationLock(client, 'sammo-legacy-gateway-v1', run);
} else {
await run();
}
} finally {
client?.release();
}
return { command: 'gateway', apply, counts, excluded };
};
+15
View File
@@ -0,0 +1,15 @@
import { createHash } from 'node:crypto';
const LEGACY_USER_NAMESPACE = 'sammo-ts:legacy-root-member:v1';
export const legacyUserId = (memberNo: number): string => {
if (!Number.isSafeInteger(memberNo) || memberNo <= 0) {
throw new Error(`Legacy member number must be a positive safe integer: ${memberNo}`);
}
const bytes = createHash('sha256').update(`${LEGACY_USER_NAMESPACE}:${memberNo}`).digest().subarray(0, 16);
bytes[6] = (bytes[6]! & 0x0f) | 0x50;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = bytes.toString('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
};
+35
View File
@@ -0,0 +1,35 @@
import { argon2, randomBytes } from 'node:crypto';
const MEMORY_KIB = 19 * 1024;
const PASSES = 2;
const PARALLELISM = 1;
const TAG_LENGTH = 32;
const PREFIX = `$argon2id$v=19$m=${MEMORY_KIB},t=${PASSES},p=${PARALLELISM}$`;
export const hashPasswordForReset = async (password: string): Promise<{ hash: string; salt: string }> => {
const salt = randomBytes(16);
const derived = await new Promise<Buffer>((resolve, reject) => {
argon2(
'argon2id',
{
message: Buffer.from(password, 'utf8'),
nonce: salt,
parallelism: PARALLELISM,
tagLength: TAG_LENGTH,
memory: MEMORY_KIB,
passes: PASSES,
},
(error, result) => {
if (error) {
reject(error);
return;
}
resolve(result);
}
);
});
return {
hash: `${PREFIX}${salt.toString('base64url')}$${derived.toString('base64url')}`,
salt: '',
};
};
+115
View File
@@ -0,0 +1,115 @@
import { legacyUserId } from './identity.js';
export type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| {
[key: string]: JsonValue;
};
export const parseJson = (value: unknown, context: string): JsonValue => {
if (value === null || typeof value === 'boolean' || typeof value === 'number') {
return value;
}
if (typeof value === 'object') {
return value as JsonValue;
}
if (typeof value !== 'string') {
throw new Error(`${context}: expected JSON text`);
}
try {
return JSON.parse(value) as JsonValue;
} catch (error) {
throw new Error(`${context}: invalid JSON`, { cause: error });
}
};
const asObject = (value: JsonValue): Record<string, JsonValue> =>
value !== null && !Array.isArray(value) && typeof value === 'object' ? value : {};
const asStringArray = (value: JsonValue | undefined): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
const legacyAclRoleMap: Record<string, string> = {
openClose: 'admin.profiles.manage',
reset: 'admin.reset.schedule',
update: 'admin.profiles.manage',
fullUpdate: 'admin.profiles.manage',
vote: 'admin.survey.open',
globalNotice: 'admin.notice.manage',
notice: 'admin.notice.manage',
blockGeneral: 'admin.users.manage',
};
export const mapLegacyRoles = (grade: number, rawAcl: JsonValue): string[] => {
const roles = new Set<string>(['user']);
if (grade >= 7) {
roles.add('superuser');
} else if (grade === 6) {
roles.add('admin.profiles.manage');
roles.add('admin.notice.manage');
roles.add('admin.reset.schedule');
roles.add('admin.resume.when-stopped');
} else if (grade === 5) {
roles.add('admin.users.manage');
roles.add('admin.users.create');
}
for (const [profile, permissions] of Object.entries(asObject(rawAcl))) {
for (const permission of asStringArray(permissions)) {
const mapped = legacyAclRoleMap[permission];
if (mapped) {
roles.add(`${mapped}:${profile}:default`);
} else {
roles.add(`legacy.acl.${permission}:${profile}`);
}
}
}
return [...roles].sort();
};
export const mapLegacySanctions = (grade: number, penalty: JsonValue): JsonValue => {
const flags = grade === 0 ? ['legacy-blocked'] : [];
return {
...(flags.length ? { flags, suspendedUntil: '9999-12-31T23:59:59.999Z' } : {}),
legacyPenalty: penalty,
};
};
export const classifyGameStorage = (
namespace: string,
key: string
): 'persistent-projected' | 'persistent-archive' | 'season-state' => {
if (/^inheritance_\d+$/.test(namespace)) {
return 'persistent-projected';
}
if (/^user_\d+$/.test(namespace)) {
return key === 'last_stat_reset' ? 'persistent-projected' : 'persistent-archive';
}
return 'season-state';
};
export const parseStorageUserId = (namespace: string, prefix: 'inheritance' | 'user'): string | null => {
const match = new RegExp(`^${prefix}_(\\d+)$`).exec(namespace);
if (!match?.[1]) {
return null;
}
return legacyUserId(Number.parseInt(match[1], 10));
};
export const parseInheritanceValue = (value: JsonValue, context: string): number => {
const scalar = Array.isArray(value) ? value[0] : value;
if (typeof scalar === 'number' && Number.isFinite(scalar)) {
return scalar;
}
if (typeof scalar === 'string') {
const parsed = Number(scalar);
if (Number.isFinite(parsed)) {
return parsed;
}
}
throw new Error(`${context}: inheritance value must be numeric`);
};
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { legacyUserId } from '../src/identity.js';
import { classifyGameStorage, mapLegacyRoles, parseInheritanceValue, parseStorageUserId } from '../src/transform.js';
describe('legacy database transforms', () => {
it('creates stable UUID-shaped user IDs without exposing the legacy sequence', () => {
expect(legacyUserId(42)).toBe(legacyUserId(42));
expect(legacyUserId(42)).not.toBe(legacyUserId(43));
expect(legacyUserId(42)).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
it('maps legacy grades and scoped ACL without granting a scoped operator superuser', () => {
expect(mapLegacyRoles(7, {})).toContain('superuser');
expect(mapLegacyRoles(5, {})).toEqual(['admin.users.create', 'admin.users.manage', 'user']);
expect(mapLegacyRoles(1, { che: ['reset', 'notice'] })).toEqual([
'admin.notice.manage:che:default',
'admin.reset.schedule:che:default',
'user',
]);
});
it('separates persistent storage projections from current-season state', () => {
expect(classifyGameStorage('inheritance_42', 'previous')).toBe('persistent-projected');
expect(classifyGameStorage('user_42', 'last_stat_reset')).toBe('persistent-projected');
expect(classifyGameStorage('game_env', 'year')).toBe('season-state');
expect(parseStorageUserId('inheritance_42', 'inheritance')).toBe(legacyUserId(42));
expect(parseInheritanceValue('123.5', 'fixture')).toBe(123.5);
expect(parseInheritanceValue([123.5, null], 'fixture')).toBe(123.5);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "dist",
"rootDir": ".",
"types": ["node"]
},
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
}
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.ts'],
},
});