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
+6 -1
View File
@@ -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"
},
+15 -2
View File
@@ -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,
},
@@ -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")
}
+84
View File
@@ -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")
}
+18
View File
@@ -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<GamePrismaClient> =>
createPostgresConnector(
config,
(options) => new GamePrismaClient(options)
);
+19
View File
@@ -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<GatewayPrismaClient> =>
createPostgresConnector(
config,
(options) => new GatewayPrismaClient(options)
);
+15
View File
@@ -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';
+6 -7
View File
@@ -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(
+74 -12
View File
@@ -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<TClient = unknown> {
disconnect(): Promise<void>;
}
export interface PrismaClientFactoryOptions {
adapter: PrismaPg;
log?: PostgresLogOption[];
}
export type PrismaClientFactory<TClient> = (
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 = <TClient>(
config: PostgresConfig,
createClient: PrismaClientFactory<TClient>
): PostgresConnector<TClient> => {
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<void> }).$connect(),
disconnect: async () => {
await prisma.$disconnect();
await (prisma as { $disconnect: () => Promise<void> }).$disconnect();
await pool.end();
},
};