feat: add database schema support for gateway and game profiles

- Introduced `dbSchema` configuration option in GatewayApiConfig and GatewayOrchestratorConfig.
- Implemented schema resolution logic in environment configuration functions.
- Updated context and orchestrator factory to use GatewayPrismaClient instead of PrismaClient.
- Refactored orchestrator server and profile repository to accommodate new database schema handling.
- Created separate Prisma schemas for game and gateway in the infra package.
- Enhanced Postgres connector to support schema overrides in database URLs.
- Updated documentation to reflect changes in database schema handling.
- Added new Prisma generation and database push scripts for game and gateway schemas.
This commit is contained in:
2026-01-03 14:47:28 +00:00
parent 67d995c65f
commit a9609dcc19
33 changed files with 388 additions and 158 deletions
+35 -3
View File
@@ -11,6 +11,7 @@ type EnvMap = Record<string, string | undefined>;
export interface DatabaseUrlOptions {
envFile?: string;
env?: NodeJS.ProcessEnv;
schema?: string;
}
const parseEnvFile = (rawText: string): EnvMap => {
@@ -47,18 +48,42 @@ const loadEnvFile = async (envFile: string): Promise<EnvMap> => {
}
};
const applySchemaToDatabaseUrl = (
url: string,
schema: string | undefined
): string => {
if (!schema) {
return url;
}
try {
const parsed = new URL(url);
parsed.searchParams.set('schema', schema);
return parsed.toString();
} catch {
return url;
}
};
export const resolveDatabaseUrl = async (
options?: DatabaseUrlOptions
): Promise<string> => {
const env = options?.env ?? process.env;
if (env.DATABASE_URL) {
return env.DATABASE_URL;
const schema =
options?.schema ??
env.POSTGRES_SCHEMA ??
env.DATABASE_SCHEMA;
return applySchemaToDatabaseUrl(env.DATABASE_URL, schema);
}
const envFile = options?.envFile ?? DEFAULT_ENV_FILE;
const fileEnv = await loadEnvFile(envFile);
if (fileEnv.DATABASE_URL) {
return fileEnv.DATABASE_URL;
const schema =
options?.schema ??
fileEnv.POSTGRES_SCHEMA ??
fileEnv.DATABASE_SCHEMA;
return applySchemaToDatabaseUrl(fileEnv.DATABASE_URL, schema);
}
const host = env.POSTGRES_HOST ?? fileEnv.POSTGRES_HOST ?? '127.0.0.1';
@@ -66,5 +91,12 @@ export const resolveDatabaseUrl = async (
const user = env.POSTGRES_USER ?? fileEnv.POSTGRES_USER ?? 'sammo';
const password = env.POSTGRES_PASSWORD ?? fileEnv.POSTGRES_PASSWORD ?? '';
const dbName = env.POSTGRES_DB ?? fileEnv.POSTGRES_DB ?? 'sammo';
return `postgresql://${user}:${password}@${host}:${port}/${dbName}?schema=public`;
const schema =
options?.schema ??
env.POSTGRES_SCHEMA ??
fileEnv.POSTGRES_SCHEMA ??
env.DATABASE_SCHEMA ??
fileEnv.DATABASE_SCHEMA ??
'public';
return `postgresql://${user}:${password}@${host}:${port}/${dbName}?schema=${schema}`;
};
@@ -1,5 +1,5 @@
import {
createPostgresConnector,
createGamePostgresConnector,
type InputJsonValue,
type TurnEngineDatabaseClient,
type TurnEngineEventCreateManyInput,
@@ -117,7 +117,7 @@ export const seedScenarioToDatabase = async (
},
});
const connector = createPostgresConnector({ url: options.databaseUrl });
const connector = createGamePostgresConnector({ url: options.databaseUrl });
const now = options.now ?? new Date();
const tickSeconds = options.tickSeconds ?? DEFAULT_TICK_SECONDS;
const generalGold = options.defaultGeneralGold ?? DEFAULT_GENERAL_GOLD;
@@ -125,7 +125,7 @@ export const seedScenarioToDatabase = async (
await connector.connect();
try {
const prisma = connector.prisma as TurnEngineDatabaseClient;
const prisma = connector.prisma as unknown as TurnEngineDatabaseClient;
if (options.resetTables ?? true) {
await prisma.event.deleteMany();
+12 -2
View File
@@ -9,6 +9,7 @@ export interface TurnDaemonCliOptions {
profileName?: string;
scenario?: string;
databaseUrl?: string;
gatewayDatabaseUrl?: string;
tickMinutes?: number;
schedule?: TurnSchedule;
budget?: Partial<TurnRunBudget>;
@@ -72,14 +73,22 @@ export const runTurnDaemonCli = async (
): Promise<void> => {
const env = options.env ?? process.env;
const profile =
options.profile ?? env.TURN_PROFILE ?? env.PROFILE ?? 'che';
options.profile ?? env.TURN_PROFILE ?? env.PROFILE ?? 'hwe';
const scenario = options.scenario ?? env.TURN_SCENARIO ?? env.SCENARIO;
const profileName =
options.profileName ??
env.TURN_PROFILE_NAME ??
(scenario ? `${profile}:${scenario}` : profile);
const databaseUrl =
options.databaseUrl ?? (await resolveDatabaseUrl({ env }));
options.databaseUrl ??
(await resolveDatabaseUrl({ env, schema: profile }));
const gatewayDatabaseUrl =
options.gatewayDatabaseUrl ??
env.GATEWAY_DATABASE_URL ??
(await resolveDatabaseUrl({
env,
schema: env.GATEWAY_DB_SCHEMA ?? 'public',
}));
const budget = buildBudgetOverride(env, options.budget);
const tickMinutes =
options.tickMinutes ?? parseNumber(env.TURN_TICK_MINUTES);
@@ -93,6 +102,7 @@ export const runTurnDaemonCli = async (
profile,
profileName,
databaseUrl,
gatewayDatabaseUrl,
defaultBudget: budget,
tickMinutes,
schedule: options.schedule,
+3 -3
View File
@@ -1,5 +1,5 @@
import {
createPostgresConnector,
createGamePostgresConnector,
type InputJsonValue,
type TurnEngineCityUpdateInput,
type TurnEngineDatabaseClient,
@@ -236,9 +236,9 @@ export const createDatabaseTurnHooks = async (
options?: { reservedTurns?: InMemoryReservedTurnStore }
): Promise<DatabaseTurnHooks> => {
// 턴 처리 결과를 DB에 반영하는 훅을 만든다.
const connector = createPostgresConnector({ url: databaseUrl });
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
const prisma = connector.prisma as TurnEngineDatabaseClient;
const prisma = connector.prisma as unknown as TurnEngineDatabaseClient;
const hooks: TurnDaemonHooks = {
flushChanges: async () => {
@@ -1,7 +1,8 @@
import { createPostgresConnector } from '@sammo-ts/infra';
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
export interface GatewayProfileGateOptions {
databaseUrl: string;
gatewayDatabaseUrl?: string;
profileName: string;
cacheMs?: number;
}
@@ -29,7 +30,9 @@ type GatewayProfileClient = {
export const createGatewayProfileGate = async (
options: GatewayProfileGateOptions
): Promise<GatewayProfileGate> => {
const connector = createPostgresConnector({ url: options.databaseUrl });
const connector = createGatewayPostgresConnector({
url: options.gatewayDatabaseUrl ?? options.databaseUrl,
});
await connector.connect();
const prisma = connector.prisma as unknown as {
gatewayProfile: GatewayProfileClient;
@@ -1,5 +1,5 @@
import {
createPostgresConnector,
createGamePostgresConnector,
type InputJsonValue,
type TurnEngineDatabaseClient,
} from '@sammo-ts/infra';
@@ -269,10 +269,10 @@ export class InMemoryReservedTurnStore {
export const createReservedTurnStore = async (
options: ReservedTurnStoreOptions
): Promise<ReservedTurnStoreHandle> => {
const connector = createPostgresConnector({ url: options.databaseUrl });
const connector = createGamePostgresConnector({ url: options.databaseUrl });
await connector.connect();
const store = new InMemoryReservedTurnStore(
connector.prisma as ReservedTurnDatabaseClient,
connector.prisma as unknown as ReservedTurnDatabaseClient,
{
maxGeneralTurns: options.maxGeneralTurns ?? DEFAULT_GENERAL_TURNS,
maxNationTurns: options.maxNationTurns ?? DEFAULT_NATION_TURNS,
+2
View File
@@ -30,6 +30,7 @@ export interface TurnDaemonRuntimeOptions {
profile: string;
profileName?: string;
databaseUrl: string;
gatewayDatabaseUrl?: string;
defaultBudget?: TurnRunBudget;
clock?: Clock;
controlQueue?: TurnDaemonControlQueue;
@@ -137,6 +138,7 @@ export const createTurnDaemonRuntime = async (
options.profileName
? await createGatewayProfileGate({
databaseUrl: options.databaseUrl,
gatewayDatabaseUrl: options.gatewayDatabaseUrl,
profileName: options.profileName,
cacheMs: options.pauseGateIntervalMs,
})
+3 -3
View File
@@ -1,5 +1,5 @@
import {
createPostgresConnector,
createGamePostgresConnector,
type JsonValue,
type TurnEngineCityRow,
type TurnEngineDatabaseClient,
@@ -252,10 +252,10 @@ const mapTroopRow = (row: TurnEngineTroopRow): Troop => ({
export const loadTurnWorldFromDatabase = async (
options: TurnWorldLoaderOptions
): Promise<TurnWorldLoadResult> => {
const connector = createPostgresConnector({ url: options.databaseUrl });
const connector = createGamePostgresConnector({ url: options.databaseUrl });
await connector.connect();
try {
const prisma = connector.prisma as TurnEngineDatabaseClient;
const prisma = connector.prisma as unknown as TurnEngineDatabaseClient;
const worldState = await prisma.worldState.findFirst();
if (!worldState) {
throw new Error('world_state row is required to start turn daemon.');