diff --git a/.env.ci.example b/.env.ci.example index a200971..b5cd784 100644 --- a/.env.ci.example +++ b/.env.ci.example @@ -1,18 +1,20 @@ POSTGRES_DB=sammo_test POSTGRES_USER=sammo POSTGRES_PASSWORD=ci-postgres +POSTGRES_SCHEMA=public REDIS_PASSWORD=ci-redis REDIS_HOST=127.0.0.1 REDIS_PORT=16379 REDIS_DB=0 GAME_TOKEN_SECRET=ci-secret GATEWAY_REDIS_PREFIX=sammo:gateway +GATEWAY_DB_SCHEMA=public GATEWAY_API_HOST=127.0.0.1 GATEWAY_API_PORT=13000 GATEWAY_PUBLIC_URL=http://localhost:13000 GAME_API_HOST=127.0.0.1 GAME_API_PORT=14000 -PROFILE=che +PROFILE=hwe SCENARIO=default KAKAO_REST_KEY=ci-kakao-rest-key KAKAO_REDIRECT_URI=http://localhost:13000/oauth/kakao/callback diff --git a/.env.example b/.env.example index f1b663f..14617c8 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,9 @@ POSTGRES_PORT=15432 POSTGRES_DB=sammo POSTGRES_USER=sammo POSTGRES_PASSWORD=change-me +# POSTGRES_SCHEMA=public # DATABASE_URL=postgresql://sammo:change-me@127.0.0.1:15432/sammo?schema=public +# GATEWAY_DATABASE_URL=postgresql://sammo:change-me@127.0.0.1:15432/sammo?schema=public # Redis REDIS_HOST=127.0.0.1 @@ -21,6 +23,7 @@ GATEWAY_API_HOST=0.0.0.0 GATEWAY_API_PORT=13000 GATEWAY_PUBLIC_URL=http://localhost:13000 GATEWAY_REDIS_PREFIX=sammo:gateway +GATEWAY_DB_SCHEMA=public SESSION_TTL_SECONDS=604800 GAME_SESSION_TTL_SECONDS=21600 OAUTH_SESSION_TTL_SECONDS=600 @@ -31,7 +34,7 @@ KAKAO_REDIRECT_URI=http://localhost:13000/oauth/kakao/callback # Game API GAME_API_HOST=0.0.0.0 GAME_API_PORT=14000 -PROFILE=che +PROFILE=hwe SCENARIO=default DAEMON_REQUEST_TIMEOUT_MS=5000 TRPC_PATH=/trpc diff --git a/.env.ops.example b/.env.ops.example index 108bf80..c148519 100644 --- a/.env.ops.example +++ b/.env.ops.example @@ -1,18 +1,20 @@ POSTGRES_DB=sammo POSTGRES_USER=sammo POSTGRES_PASSWORD=change-me +POSTGRES_SCHEMA=public REDIS_PASSWORD=change-me REDIS_HOST=127.0.0.1 REDIS_PORT=16379 REDIS_DB=0 GAME_TOKEN_SECRET=change-me GATEWAY_REDIS_PREFIX=sammo:gateway +GATEWAY_DB_SCHEMA=public GATEWAY_API_HOST=0.0.0.0 GATEWAY_API_PORT=13000 GATEWAY_PUBLIC_URL=https://example.com GAME_API_HOST=0.0.0.0 GAME_API_PORT=14000 -PROFILE=che +PROFILE=hwe SCENARIO=default KAKAO_REST_KEY=your-kakao-rest-key KAKAO_ADMIN_KEY=your-kakao-admin-key diff --git a/.gitignore b/.gitignore index 2f00161..e17066d 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,7 @@ out # Nuxt.js build / generate output .nuxt dist +packages/infra/prisma/generated/ # Gatsby files .cache/ diff --git a/app/game-api/src/config.ts b/app/game-api/src/config.ts index 7372054..3cc47c5 100644 --- a/app/game-api/src/config.ts +++ b/app/game-api/src/config.ts @@ -26,7 +26,7 @@ const parseNumber = (value: string | undefined, fallback: number, label: string) export const resolveGameApiConfigFromEnv = ( env: NodeJS.ProcessEnv = process.env ): GameApiConfig => { - const profile = env.PROFILE ?? env.SERVER_PROFILE ?? 'che'; + const profile = env.PROFILE ?? env.SERVER_PROFILE ?? 'hwe'; const scenario = env.SCENARIO ?? 'default'; const profileName = `${profile}:${scenario}`; const secret = env.GAME_TOKEN_SECRET ?? env.GATEWAY_TOKEN_SECRET ?? ''; diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 999620a..81db134 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -2,7 +2,7 @@ import fastify, { type FastifyRequest } from 'fastify'; import cors from '@fastify/cors'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { - createPostgresConnector, + createGamePostgresConnector, createRedisConnector, resolvePostgresConfigFromEnv, resolveRedisConfigFromEnv, @@ -35,7 +35,9 @@ const extractBearerToken = (value: string | string[] | undefined): string | null export const createGameApiServer = async () => { const config = resolveGameApiConfigFromEnv(); - const postgres = createPostgresConnector(resolvePostgresConfigFromEnv()); + const postgres = createGamePostgresConnector( + resolvePostgresConfigFromEnv({ schema: config.profile }) + ); const redis = createRedisConnector(resolveRedisConfigFromEnv()); await postgres.connect(); diff --git a/app/game-engine/src/scenario/databaseUrl.ts b/app/game-engine/src/scenario/databaseUrl.ts index 2a7351b..91c5c98 100644 --- a/app/game-engine/src/scenario/databaseUrl.ts +++ b/app/game-engine/src/scenario/databaseUrl.ts @@ -11,6 +11,7 @@ type EnvMap = Record; 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 => { } }; +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 => { 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}`; }; diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 2425a01..8991548 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -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(); diff --git a/app/game-engine/src/turn/cli.ts b/app/game-engine/src/turn/cli.ts index ce3e2b1..89b32af 100644 --- a/app/game-engine/src/turn/cli.ts +++ b/app/game-engine/src/turn/cli.ts @@ -9,6 +9,7 @@ export interface TurnDaemonCliOptions { profileName?: string; scenario?: string; databaseUrl?: string; + gatewayDatabaseUrl?: string; tickMinutes?: number; schedule?: TurnSchedule; budget?: Partial; @@ -72,14 +73,22 @@ export const runTurnDaemonCli = async ( ): Promise => { 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, diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 7cca985..cd64f02 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -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 => { // 턴 처리 결과를 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 () => { diff --git a/app/game-engine/src/turn/gatewayProfileGate.ts b/app/game-engine/src/turn/gatewayProfileGate.ts index af5df80..0c2bd90 100644 --- a/app/game-engine/src/turn/gatewayProfileGate.ts +++ b/app/game-engine/src/turn/gatewayProfileGate.ts @@ -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 => { - 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; diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts index 90f90fa..0847cb6 100644 --- a/app/game-engine/src/turn/reservedTurnStore.ts +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -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 => { - 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, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index f62268b..f36a7bf 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -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, }) diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index b99032b..821c34f 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -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 => { - 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.'); diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 5a5c187..921b371 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -1,10 +1,12 @@ -import { createPostgresConnector } from '@sammo-ts/infra'; +import { createGamePostgresConnector } from '@sammo-ts/infra'; import { describe, expect, test } from 'vitest'; import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js'; import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js'; const scenarioId = 1010; -const databaseUrl = await resolveDatabaseUrl(); +const schema = process.env.POSTGRES_SCHEMA ?? 'public'; +process.env.POSTGRES_SCHEMA = schema; +const databaseUrl = await resolveDatabaseUrl({ schema }); type ScenarioSeederPrismaClient = { $queryRawUnsafe(query: string): Promise; @@ -26,7 +28,7 @@ type ScenarioSeederPrismaClient = { }; const canConnectToDatabase = async (url: string): Promise => { - const connector = createPostgresConnector({ url }); + const connector = createGamePostgresConnector({ url }); try { await connector.connect(); const prisma = connector.prisma as ScenarioSeederPrismaClient; @@ -49,7 +51,7 @@ describeDb('scenario database seed', () => { databaseUrl, }); - const connector = createPostgresConnector({ url: databaseUrl }); + const connector = createGamePostgresConnector({ url: databaseUrl }); await connector.connect(); try { const prisma = connector.prisma as ScenarioSeederPrismaClient; diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index 6cac303..fe5f493 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -1,4 +1,4 @@ -import { Prisma, type PrismaClient } from '@prisma/client'; +import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra'; import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js'; import type { @@ -29,12 +29,12 @@ const mapUser = (row: { displayName: string; passwordHash: string; passwordSalt: string; - roles: Prisma.JsonValue; - sanctions: Prisma.JsonValue; + roles: GatewayPrisma.JsonValue; + sanctions: GatewayPrisma.JsonValue; oauthType: 'NONE' | 'KAKAO'; oauthId: string | null; email: string | null; - oauthInfo: Prisma.JsonValue; + oauthInfo: GatewayPrisma.JsonValue; createdAt: Date; }): UserRecord => ({ id: row.id, @@ -52,7 +52,7 @@ const mapUser = (row: { }); export const createPostgresUserRepository = ( - prisma: PrismaClient, + prisma: GatewayPrismaClient, hasher: PasswordHasher = createSimplePasswordHasher() ): UserRepository => { return { @@ -98,12 +98,12 @@ export const createPostgresUserRepository = ( displayName: input.displayName ?? input.username, passwordHash: hasher.hash(input.password, salt), passwordSalt: salt, - roles: ['user'] satisfies Prisma.JsonArray, - sanctions: {} satisfies Prisma.JsonObject, + roles: ['user'] satisfies GatewayPrisma.JsonArray, + sanctions: {} satisfies GatewayPrisma.JsonObject, oauthType, oauthId: input.oauth?.id, email: input.oauth?.email?.toLowerCase(), - oauthInfo: (input.oauth?.info ?? {}) as Prisma.JsonObject, + oauthInfo: (input.oauth?.info ?? {}) as GatewayPrisma.JsonObject, }, }); return mapUser(row); @@ -125,7 +125,7 @@ export const createPostgresUserRepository = ( await prisma.appUser.update({ where: { id: userId }, data: { - oauthInfo: oauthInfo as Prisma.JsonObject, + oauthInfo: oauthInfo as GatewayPrisma.JsonObject, }, }); }, diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index 63972f9..d5f02fe 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -4,6 +4,7 @@ export interface GatewayApiConfig { host: string; port: number; trpcPath: string; + dbSchema: string; redisKeyPrefix: string; flushChannel: string; sessionTtlSeconds: number; @@ -24,6 +25,7 @@ export interface GatewayApiConfig { } export interface GatewayOrchestratorConfig { + dbSchema: string; redisKeyPrefix: string; gameTokenSecret: string; orchestratorReconcileIntervalMs: number; @@ -58,6 +60,14 @@ const parseBoolean = (value: string | undefined, fallback: boolean): boolean => return fallback; }; +const resolveSchemaName = (value: string | undefined): string => { + if (!value) { + return 'public'; + } + const trimmed = value.trim(); + return trimmed ? trimmed : 'public'; +}; + export const resolveGatewayApiConfigFromEnv = ( env: NodeJS.ProcessEnv = process.env ): GatewayApiConfig => { @@ -76,6 +86,7 @@ export const resolveGatewayApiConfigFromEnv = ( host: env.GATEWAY_API_HOST ?? '0.0.0.0', port: parseNumber(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT'), trpcPath: env.TRPC_PATH ?? '/trpc', + dbSchema: resolveSchemaName(env.GATEWAY_DB_SCHEMA), redisKeyPrefix, flushChannel: `${redisKeyPrefix}:flush`, sessionTtlSeconds: parseNumber(env.SESSION_TTL_SECONDS, 60 * 60 * 24 * 7, 'SESSION_TTL_SECONDS'), @@ -127,6 +138,7 @@ export const resolveGatewayOrchestratorConfigFromEnv = ( } const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway'; return { + dbSchema: resolveSchemaName(env.GATEWAY_DB_SCHEMA), redisKeyPrefix, gameTokenSecret: secret, orchestratorReconcileIntervalMs: parseNumber( diff --git a/app/gateway-api/src/context.ts b/app/gateway-api/src/context.ts index 4cbbd41..f844a8e 100644 --- a/app/gateway-api/src/context.ts +++ b/app/gateway-api/src/context.ts @@ -6,7 +6,7 @@ import type { OAuthSessionStore } from './auth/oauthSessionStore.js'; import type { GatewayProfileRepository } from './orchestrator/profileRepository.js'; import type { GatewayOrchestratorHandle } from './orchestrator/gatewayOrchestrator.js'; import type { GatewayProfileStatusService } from './lobby/profileStatusService.js'; -import type { PrismaClient } from '@prisma/client'; +import type { GatewayPrismaClient } from '@sammo-ts/infra'; export interface GatewayApiContext { users: UserRepository; @@ -22,7 +22,7 @@ export interface GatewayApiContext { profileStatus: GatewayProfileStatusService; adminToken?: string; requestHeaders: Record; - prisma: PrismaClient; + prisma: GatewayPrismaClient; } export const createGatewayApiContext = (options: { @@ -39,7 +39,7 @@ export const createGatewayApiContext = (options: { profileStatus: GatewayProfileStatusService; adminToken?: string; requestHeaders?: Record; - prisma: PrismaClient; + prisma: GatewayPrismaClient; }): GatewayApiContext => ({ users: options.users, sessions: options.sessions, diff --git a/app/gateway-api/src/orchestrator/orchestratorFactory.ts b/app/gateway-api/src/orchestrator/orchestratorFactory.ts index b34438a..36a274e 100644 --- a/app/gateway-api/src/orchestrator/orchestratorFactory.ts +++ b/app/gateway-api/src/orchestrator/orchestratorFactory.ts @@ -1,4 +1,4 @@ -import type { PrismaClient } from '@prisma/client'; +import type { GatewayPrismaClient } from '@sammo-ts/infra'; import type { GatewayOrchestratorConfig } from '../config.js'; import { createGatewayProfileRepository } from './profileRepository.js'; @@ -16,7 +16,7 @@ export const buildEnvMap = (env: NodeJS.ProcessEnv): Record => { }; export const createGatewayOrchestrator = ( - prisma: PrismaClient, + prisma: GatewayPrismaClient, config: GatewayOrchestratorConfig, env: NodeJS.ProcessEnv = process.env ): { diff --git a/app/gateway-api/src/orchestrator/orchestratorServer.ts b/app/gateway-api/src/orchestrator/orchestratorServer.ts index a20c33e..9b450fb 100644 --- a/app/gateway-api/src/orchestrator/orchestratorServer.ts +++ b/app/gateway-api/src/orchestrator/orchestratorServer.ts @@ -1,6 +1,6 @@ -import type { PrismaClient } from '@prisma/client'; import { - createPostgresConnector, + createGatewayPostgresConnector, + type GatewayPrismaClient, resolvePostgresConfigFromEnv, } from '@sammo-ts/infra'; @@ -9,11 +9,13 @@ import { createGatewayOrchestrator } from './orchestratorFactory.js'; export const runGatewayOrchestrator = async (): Promise => { const config = resolveGatewayOrchestratorConfigFromEnv(); - const postgres = createPostgresConnector(resolvePostgresConfigFromEnv()); + const postgres = createGatewayPostgresConnector( + resolvePostgresConfigFromEnv({ schema: config.dbSchema }) + ); await postgres.connect(); const { orchestrator } = createGatewayOrchestrator( - postgres.prisma as PrismaClient, + postgres.prisma as GatewayPrismaClient, config, process.env ); diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index bd22407..f3487f3 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -1,4 +1,4 @@ -import { Prisma, type PrismaClient } from '@prisma/client'; +import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra'; export const GATEWAY_PROFILE_STATUSES = [ 'RESERVED', @@ -38,7 +38,7 @@ export interface GatewayProfileRecord { buildCompletedAt?: string; buildError?: string; lastError?: string; - meta: Prisma.JsonObject; + meta: GatewayPrisma.JsonObject; createdAt: string; updatedAt: string; } @@ -52,7 +52,7 @@ export interface GatewayProfileUpsertInput { openAt?: string; scheduledStartAt?: string; buildCommitSha?: string; - meta?: Prisma.JsonObject; + meta?: GatewayPrisma.JsonObject; } export interface GatewayProfileRepository { @@ -113,7 +113,7 @@ type GatewayProfileRow = { buildCompletedAt: Date | null; buildError: string | null; lastError: string | null; - meta: Prisma.JsonValue; + meta: GatewayPrisma.JsonValue; createdAt: Date; updatedAt: Date; }; @@ -145,7 +145,7 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({ buildCompletedAt: toIso(row.buildCompletedAt), buildError: row.buildError ?? undefined, lastError: row.lastError ?? undefined, - meta: (row.meta ?? {}) as Prisma.JsonObject, + meta: (row.meta ?? {}) as GatewayPrisma.JsonObject, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }); @@ -154,7 +154,7 @@ const buildProfileName = (profile: string, scenario: string): string => `${profile}:${scenario}`; export const createGatewayProfileRepository = ( - prisma: PrismaClient + prisma: GatewayPrismaClient ): GatewayProfileRepository => ({ async listProfiles(): Promise { const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient; @@ -187,7 +187,7 @@ export const createGatewayProfileRepository = ( ? new Date(input.scheduledStartAt) : null, buildCommitSha: input.buildCommitSha ?? null, - meta: (input.meta ?? {}) as Prisma.JsonObject, + meta: (input.meta ?? {}) as GatewayPrisma.JsonObject, }, update: { apiPort: input.apiPort, @@ -211,7 +211,7 @@ export const createGatewayProfileRepository = ( input.buildCommitSha === undefined ? undefined : input.buildCommitSha, - meta: input.meta ? (input.meta as Prisma.JsonObject) : undefined, + meta: input.meta ? (input.meta as GatewayPrisma.JsonObject) : undefined, }, }); return mapProfile(row); diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index f69c1d8..ee9fccf 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -1,10 +1,10 @@ import fastify, { type FastifyRequest } from 'fastify'; import cors from '@fastify/cors'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; -import type { PrismaClient } from '@prisma/client'; import { - createPostgresConnector, + createGatewayPostgresConnector, createRedisConnector, + type GatewayPrismaClient, resolvePostgresConfigFromEnv, resolveRedisConfigFromEnv, } from '@sammo-ts/infra'; @@ -22,13 +22,15 @@ import { RepositoryProfileStatusService } from './lobby/profileStatusService.js' export const createGatewayApiServer = async () => { const config = resolveGatewayApiConfigFromEnv(); - const postgres = createPostgresConnector(resolvePostgresConfigFromEnv()); + const postgres = createGatewayPostgresConnector( + resolvePostgresConfigFromEnv({ schema: config.dbSchema }) + ); const redis = createRedisConnector(resolveRedisConfigFromEnv()); await postgres.connect(); await redis.connect(); const users = createPostgresUserRepository( - postgres.prisma as PrismaClient + postgres.prisma as GatewayPrismaClient ); const sessions = new RedisGatewaySessionService(redis.client, { keyPrefix: config.redisKeyPrefix, @@ -48,7 +50,7 @@ export const createGatewayApiServer = async () => { ); const { orchestrator, profiles } = createGatewayOrchestrator( - postgres.prisma as PrismaClient, + postgres.prisma as GatewayPrismaClient, config, process.env ); @@ -82,7 +84,7 @@ export const createGatewayApiServer = async () => { profileStatus, adminToken: config.adminToken, requestHeaders: req.headers, - prisma: postgres.prisma as PrismaClient, + prisma: postgres.prisma as GatewayPrismaClient, }), }, }); diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index bf3da22..d1083a7 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -4,6 +4,16 @@ Build outputs should be emitted to `/dist/{profileName}` per profile to keep deployments predictable. Profiles are server+scenario pairs, and scenario selection is required because it drives unit sets and DB settings. +## Database Schemas (Gateway vs Game) + +Gateway uses a shared schema (default `public`) for login/profile state, while +each game profile runs against its own schema. This keeps gateway data stable +and allows profile-scoped game data resets. + +- Gateway schema: `GATEWAY_DB_SCHEMA` (default `public`) +- Game schema: `PROFILE` value (e.g., `hwe`, `che`) +- Optional override for gateway DB URL: `GATEWAY_DATABASE_URL` + ## Suggested Build Pattern - Wrapper script under `tools/build-scripts` diff --git a/packages/infra/package.json b/packages/infra/package.json index 89dcd40..3010229 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -10,11 +10,16 @@ "dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/infra --watch", "lint": "node -e \"console.log('lint not configured')\"", "test": "node -e \"console.log('test not configured')\"", - "typecheck": "tsc --noEmit" + "typecheck": "tsc -b", + "prisma:generate:game": "prisma generate --schema prisma/game.prisma", + "prisma:generate:gateway": "prisma generate --schema prisma/gateway.prisma", + "prisma:db:push:game": "prisma db push --schema prisma/game.prisma", + "prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma" }, "dependencies": { "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", + "@prisma/client-runtime-utils": "^7.2.0", "pg": "^8.16.3", "redis": "^5.10.0" }, diff --git a/packages/infra/prisma.config.ts b/packages/infra/prisma.config.ts index bf68499..d9a321c 100644 --- a/packages/infra/prisma.config.ts +++ b/packages/infra/prisma.config.ts @@ -2,20 +2,33 @@ import 'dotenv/config'; import { defineConfig } from 'prisma/config'; +const resolveSchemaName = (value: string | undefined): string => { + if (!value) { + return 'public'; + } + const trimmed = value.trim(); + return trimmed ? trimmed : 'public'; +}; + const buildDatabaseUrlFromEnv = (): string => { const host = process.env.POSTGRES_HOST ?? '127.0.0.1'; const port = process.env.POSTGRES_PORT ?? '15432'; const user = process.env.POSTGRES_USER ?? 'sammo'; const password = process.env.POSTGRES_PASSWORD ?? ''; const dbName = process.env.POSTGRES_DB ?? 'sammo'; - return `postgresql://${user}:${password}@${host}:${port}/${dbName}?schema=public`; + const schema = resolveSchemaName( + process.env.POSTGRES_SCHEMA ?? process.env.DATABASE_SCHEMA + ); + return `postgresql://${user}:${password}@${host}:${port}/${dbName}?schema=${schema}`; }; const databaseUrl = process.env.DATABASE_URL ?? buildDatabaseUrlFromEnv(); +const schemaPath = process.env.PRISMA_SCHEMA ?? 'prisma/game.prisma'; + export default defineConfig({ - schema: 'prisma/schema.prisma', + schema: schemaPath, datasource: { url: databaseUrl, }, diff --git a/packages/infra/prisma/schema.prisma b/packages/infra/prisma/game.prisma similarity index 73% rename from packages/infra/prisma/schema.prisma rename to packages/infra/prisma/game.prisma index 36dc12a..b5a8699 100644 --- a/packages/infra/prisma/schema.prisma +++ b/packages/infra/prisma/game.prisma @@ -1,5 +1,7 @@ generator client { provider = "prisma-client-js" + output = "./generated/game" + engineType = "binary" } datasource db { @@ -22,74 +24,6 @@ enum LogCategory { USER } -enum OAuthType { - NONE - KAKAO -} - -enum GatewayProfileStatus { - RESERVED - PREOPEN - RUNNING - PAUSED - COMPLETED - STOPPED - DISABLED -} - -enum GatewayBuildStatus { - IDLE - QUEUED - RUNNING - FAILED - SUCCEEDED -} - -model AppUser { - id String @id @default(uuid()) - loginId String @unique @map("login_id") - displayName String @map("display_name") - passwordHash String @map("password_hash") - passwordSalt String @map("password_salt") - roles Json @default(dbgenerated("'[]'::jsonb")) - sanctions Json @default(dbgenerated("'{}'::jsonb")) - oauthType OAuthType @default(NONE) @map("oauth_type") - oauthId String? @unique @map("oauth_id") - email String? @unique - oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - lastLoginAt DateTime? @map("last_login_at") - - @@map("app_user") -} - -model GatewayProfile { - profileName String @id @map("profile_name") - profile String - scenario String - apiPort Int @map("api_port") - status GatewayProfileStatus - buildStatus GatewayBuildStatus @default(IDLE) @map("build_status") - buildCommitSha String? @map("build_commit_sha") - buildWorkspace String? @map("build_workspace") - buildLastUsedAt DateTime? @map("build_last_used_at") - preopenAt DateTime? @map("preopen_at") - openAt DateTime? @map("open_at") - scheduledStartAt DateTime? @map("scheduled_start_at") - buildRequestedAt DateTime? @map("build_requested_at") - buildStartedAt DateTime? @map("build_started_at") - buildCompletedAt DateTime? @map("build_completed_at") - buildError String? @map("build_error") - lastError String? @map("last_error") - meta Json @default(dbgenerated("'{}'::jsonb")) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - @@unique([profile, scenario]) - @@map("gateway_profile") -} - model WorldState { id Int @id @default(autoincrement()) scenarioCode String @map("scenario_code") @@ -272,10 +206,3 @@ model LogEntry { @@index([userId, category, id]) @@map("log_entry") } - -model SystemSetting { - id Int @id @default(1) @map("no") - notice String @default("") @map("notice") - - @@map("system") -} diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma new file mode 100644 index 0000000..364673c --- /dev/null +++ b/packages/infra/prisma/gateway.prisma @@ -0,0 +1,84 @@ +generator client { + provider = "prisma-client-js" + output = "./generated/gateway" + engineType = "binary" +} + +datasource db { + provider = "postgresql" +} + +enum OAuthType { + NONE + KAKAO +} + +enum GatewayProfileStatus { + RESERVED + PREOPEN + RUNNING + PAUSED + COMPLETED + STOPPED + DISABLED +} + +enum GatewayBuildStatus { + IDLE + QUEUED + RUNNING + FAILED + SUCCEEDED +} + +model AppUser { + id String @id @default(uuid()) + loginId String @unique @map("login_id") + displayName String @map("display_name") + passwordHash String @map("password_hash") + passwordSalt String @map("password_salt") + roles Json @default(dbgenerated("'[]'::jsonb")) + sanctions Json @default(dbgenerated("'{}'::jsonb")) + oauthType OAuthType @default(NONE) @map("oauth_type") + oauthId String? @unique @map("oauth_id") + email String? @unique + oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + lastLoginAt DateTime? @map("last_login_at") + + @@map("app_user") +} + +model GatewayProfile { + profileName String @id @map("profile_name") + profile String + scenario String + apiPort Int @map("api_port") + status GatewayProfileStatus + buildStatus GatewayBuildStatus @default(IDLE) @map("build_status") + buildCommitSha String? @map("build_commit_sha") + buildWorkspace String? @map("build_workspace") + buildLastUsedAt DateTime? @map("build_last_used_at") + preopenAt DateTime? @map("preopen_at") + openAt DateTime? @map("open_at") + scheduledStartAt DateTime? @map("scheduled_start_at") + buildRequestedAt DateTime? @map("build_requested_at") + buildStartedAt DateTime? @map("build_started_at") + buildCompletedAt DateTime? @map("build_completed_at") + buildError String? @map("build_error") + lastError String? @map("last_error") + meta Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([profile, scenario]) + @@map("gateway_profile") +} + +model SystemSetting { + id Int @id @default(1) @map("no") + notice String @default("") @map("notice") + + @@map("system") +} diff --git a/packages/infra/src/gamePrisma.ts b/packages/infra/src/gamePrisma.ts new file mode 100644 index 0000000..1cf03d4 --- /dev/null +++ b/packages/infra/src/gamePrisma.ts @@ -0,0 +1,18 @@ +import { PrismaClient as GamePrismaClient } from '../prisma/generated/game/index.js'; +export { + LogCategory, + LogScope, + Prisma as GamePrisma, +} from '../prisma/generated/game/index.js'; +export type { PrismaClient as GamePrismaClient } from '../prisma/generated/game/index.js'; + +import type { PostgresConfig, PostgresConnector } from './postgres.js'; +import { createPostgresConnector } from './postgres.js'; + +export const createGamePostgresConnector = ( + config: PostgresConfig +): PostgresConnector => + createPostgresConnector( + config, + (options) => new GamePrismaClient(options) + ); diff --git a/packages/infra/src/gatewayPrisma.ts b/packages/infra/src/gatewayPrisma.ts new file mode 100644 index 0000000..d0d3207 --- /dev/null +++ b/packages/infra/src/gatewayPrisma.ts @@ -0,0 +1,19 @@ +import { PrismaClient as GatewayPrismaClient } from '../prisma/generated/gateway/index.js'; +export { + GatewayBuildStatus, + GatewayProfileStatus, + OAuthType, + Prisma as GatewayPrisma, +} from '../prisma/generated/gateway/index.js'; +export type { PrismaClient as GatewayPrismaClient } from '../prisma/generated/gateway/index.js'; + +import type { PostgresConfig, PostgresConnector } from './postgres.js'; +import { createPostgresConnector } from './postgres.js'; + +export const createGatewayPostgresConnector = ( + config: PostgresConfig +): PostgresConnector => + createPostgresConnector( + config, + (options) => new GatewayPrismaClient(options) + ); diff --git a/packages/infra/src/index.ts b/packages/infra/src/index.ts index 99c14c3..c5d4c64 100644 --- a/packages/infra/src/index.ts +++ b/packages/infra/src/index.ts @@ -1,4 +1,19 @@ export * from './postgres.js'; +export { + createGamePostgresConnector, + GamePrisma, + LogCategory, + LogScope, +} from './gamePrisma.js'; +export type { GamePrismaClient } from './gamePrisma.js'; +export { + createGatewayPostgresConnector, + GatewayBuildStatus, + GatewayProfileStatus, + GatewayPrisma, + OAuthType, +} from './gatewayPrisma.js'; +export type { GatewayPrismaClient } from './gatewayPrisma.js'; export * from './db.js'; export * from './logRepository.js'; export * from './redis.js'; diff --git a/packages/infra/src/logRepository.ts b/packages/infra/src/logRepository.ts index 29beb92..3f63518 100644 --- a/packages/infra/src/logRepository.ts +++ b/packages/infra/src/logRepository.ts @@ -1,5 +1,4 @@ -import { LogCategory, LogScope } from '@prisma/client'; -import type { Prisma, PrismaClient } from '@prisma/client'; +import { LogCategory, LogScope, type GamePrisma, type GamePrismaClient } from './gamePrisma.js'; export interface LogQueryOptions { limit?: number; @@ -21,9 +20,9 @@ export interface LogEntryView { } const buildPaginationWhere = ( - base: Prisma.LogEntryWhereInput, + base: GamePrisma.LogEntryWhereInput, options: LogQueryOptions -): Prisma.LogEntryWhereInput => { +): GamePrisma.LogEntryWhereInput => { if (options.beforeId) { return { ...base, @@ -34,16 +33,16 @@ const buildPaginationWhere = ( }; const buildFindArgs = ( - where: Prisma.LogEntryWhereInput, + where: GamePrisma.LogEntryWhereInput, options: LogQueryOptions -): Prisma.LogEntryFindManyArgs => ({ +): GamePrisma.LogEntryFindManyArgs => ({ where: buildPaginationWhere(where, options), orderBy: { id: 'desc' }, take: options.limit ?? 50, }); export class LogRepository { - constructor(private readonly prisma: PrismaClient) {} + constructor(private readonly prisma: GamePrismaClient) {} // 전역(시스템) 로그 조회 async listSystemLogs( diff --git a/packages/infra/src/postgres.ts b/packages/infra/src/postgres.ts index 404d3d6..e2fc4e8 100644 --- a/packages/infra/src/postgres.ts +++ b/packages/infra/src/postgres.ts @@ -1,4 +1,3 @@ -import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; import { Pool } from 'pg'; @@ -22,21 +21,75 @@ export interface PostgresConnector { disconnect(): Promise; } +export interface PrismaClientFactoryOptions { + adapter: PrismaPg; + log?: PostgresLogOption[]; +} + +export type PrismaClientFactory = ( + options: PrismaClientFactoryOptions +) => TClient; + +const resolveSchemaName = ( + value: string | undefined +): string => { + if (!value) { + return 'public'; + } + const trimmed = value.trim(); + return trimmed ? trimmed : 'public'; +}; + +const applySchemaToDatabaseUrl = ( + url: string, + schema: string | undefined +): string => { + if (!schema) { + return url; + } + try { + const parsed = new URL(url); + parsed.searchParams.set('schema', resolveSchemaName(schema)); + return parsed.toString(); + } catch { + return url; + } +}; + +const extractSchemaFromDatabaseUrl = ( + url: string +): string | undefined => { + try { + const parsed = new URL(url); + const schema = parsed.searchParams.get('schema'); + return schema && schema.trim() ? schema.trim() : undefined; + } catch { + return undefined; + } +}; + const buildDatabaseUrlFromEnv = ( - env: NodeJS.ProcessEnv + env: NodeJS.ProcessEnv, + schemaOverride?: string ): string => { const host = env.POSTGRES_HOST ?? '127.0.0.1'; const port = env.POSTGRES_PORT ?? '15432'; const user = env.POSTGRES_USER ?? 'sammo'; const password = env.POSTGRES_PASSWORD ?? ''; const dbName = env.POSTGRES_DB ?? 'sammo'; - return `postgresql://${user}:${password}@${host}:${port}/${dbName}?schema=public`; + const schema = resolveSchemaName( + schemaOverride ?? env.POSTGRES_SCHEMA ?? env.DATABASE_SCHEMA + ); + return `postgresql://${user}:${password}@${host}:${port}/${dbName}?schema=${schema}`; }; export const resolvePostgresConfigFromEnv = ( - env: NodeJS.ProcessEnv = process.env + options: { env?: NodeJS.ProcessEnv; schema?: string } = {} ): PostgresConfig => { - const url = env.DATABASE_URL ?? buildDatabaseUrlFromEnv(env); + const env = options.env ?? process.env; + const url = env.DATABASE_URL + ? applySchemaToDatabaseUrl(env.DATABASE_URL, options.schema) + : buildDatabaseUrlFromEnv(env, options.schema); if (!url) { throw new Error('DATABASE_URL is required to create a Postgres client.'); } @@ -44,23 +97,32 @@ export const resolvePostgresConfigFromEnv = ( return { url }; }; -export const createPostgresConnector = ( - config: PostgresConfig -): PostgresConnector => { +export const createPostgresConnector = ( + config: PostgresConfig, + createClient: PrismaClientFactory +): PostgresConnector => { + const schema = + extractSchemaFromDatabaseUrl(config.url) ?? + process.env.POSTGRES_SCHEMA ?? + process.env.DATABASE_SCHEMA; const pool = new Pool({ connectionString: config.url, + ...(schema ? { options: `-c search_path=${schema}` } : {}), }); - const adapter = new PrismaPg(pool); - const prisma = new PrismaClient({ + const adapter = new PrismaPg( + pool, + schema ? { schema } : undefined + ); + const prisma = createClient({ adapter, log: config.log, }); return { prisma, - connect: () => prisma.$connect(), + connect: () => (prisma as { $connect: () => Promise }).$connect(), disconnect: async () => { - await prisma.$disconnect(); + await (prisma as { $disconnect: () => Promise }).$disconnect(); await pool.end(); }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 816c27e..dfada0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,6 +201,9 @@ importers: '@prisma/client': specifier: ^7.2.0 version: 7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) + '@prisma/client-runtime-utils': + specifier: ^7.2.0 + version: 7.2.0 pg: specifier: ^8.16.3 version: 8.16.3