Files
core2026/packages/infra/src/postgres.ts
T
Hide_D 6f2d6f0379 perf: 프로필 DB 풀과 advisory lock 격리
역할별 PostgreSQL pool 상한과 동일 process 공유 pool을 적용하고 health 지표를 노출한다.\n\n게임 기능 advisory lock을 schema namespace로 통일하고 실제 PostgreSQL 통합 하네스를 보강한다.
2026-08-17 17:30:06 +00:00

210 lines
6.5 KiB
TypeScript

import { PrismaPg } from '@prisma/adapter-pg';
import pg, { type Pool as PgPool } from 'pg';
export type PostgresLogLevel = 'query' | 'info' | 'warn' | 'error';
export type PostgresLogOption =
| PostgresLogLevel
| {
emit: 'stdout' | 'event';
level: PostgresLogLevel;
};
export interface PostgresConfig {
url: string;
log?: PostgresLogOption[];
maxConnections?: number;
}
export interface PostgresPoolStats {
max: number;
total: number;
active: number;
idle: number;
waiting: number;
}
export interface PostgresConnector<TClient = unknown> {
readonly prisma: TClient;
connect(): Promise<void>;
disconnect(): Promise<void>;
getPoolStats(): PostgresPoolStats;
}
export interface PrismaClientFactoryOptions {
adapter: PrismaPg;
log?: PostgresLogOption[];
}
export type PrismaClientFactory<TClient> = (options: PrismaClientFactoryOptions) => TClient;
export const DEFAULT_POSTGRES_POOL_MAX = 10;
const MAX_POSTGRES_POOL_MAX = 1_000;
export const resolvePostgresPoolMax = (
value: string | number | undefined,
fallback = DEFAULT_POSTGRES_POOL_MAX
): number => {
const candidate = value === undefined || value === '' ? fallback : Number(value);
if (!Number.isSafeInteger(candidate) || candidate < 1 || candidate > MAX_POSTGRES_POOL_MAX) {
throw new RangeError(`PostgreSQL pool max must be a safe integer from 1 to ${MAX_POSTGRES_POOL_MAX}.`);
}
return candidate;
};
interface SharedPoolEntry {
pool: PgPool;
references: number;
maxConnections: number;
}
const sharedPools = new Map<string, SharedPoolEntry>();
const buildSharedPoolKey = (url: string, schema: string | undefined, maxConnections: number): string =>
JSON.stringify([url, schema ?? '', maxConnections]);
const acquireSharedPool = (
url: string,
schema: string | undefined,
maxConnections: number
): { entry: SharedPoolEntry; release: () => Promise<void> } => {
const key = buildSharedPoolKey(url, schema, maxConnections);
let entry = sharedPools.get(key);
if (!entry) {
const pool = new pg.Pool({
connectionString: url,
max: maxConnections,
...(schema ? { options: `-c search_path=${schema}` } : {}),
});
entry = { pool, references: 0, maxConnections };
sharedPools.set(key, entry);
}
entry.references += 1;
let released = false;
return {
entry,
release: async () => {
if (released) return;
released = true;
entry.references -= 1;
if (entry.references === 0 && sharedPools.get(key) === entry) {
sharedPools.delete(key);
await entry.pool.end();
}
},
};
};
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, 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';
const schema = resolveSchemaName(schemaOverride ?? env.POSTGRES_SCHEMA ?? env.DATABASE_SCHEMA);
return `postgresql://${user}:${password}@${host}:${port}/${dbName}?schema=${schema}`;
};
export const resolvePostgresConfigFromEnv = (
options: { env?: NodeJS.ProcessEnv; schema?: string } = {}
): PostgresConfig => {
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.');
}
return {
url,
maxConnections: resolvePostgresPoolMax(env.POSTGRES_POOL_MAX),
};
};
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 maxConnections = resolvePostgresPoolMax(config.maxConnections ?? process.env.POSTGRES_POOL_MAX);
const sharedPool = acquireSharedPool(config.url, schema, maxConnections);
const adapter = new PrismaPg(sharedPool.entry.pool, schema ? { schema } : undefined);
const prisma = createClient({
adapter,
log: config.log,
});
let disconnected = false;
return {
prisma,
connect: () => {
if (disconnected) {
throw new Error('Postgres connector cannot reconnect after disconnect.');
}
return (prisma as { $connect: () => Promise<void> }).$connect();
},
disconnect: async () => {
if (disconnected) return;
disconnected = true;
let disconnectError: unknown;
try {
await (prisma as { $disconnect: () => Promise<void> }).$disconnect();
} catch (error) {
disconnectError = error;
}
try {
await sharedPool.release();
} catch (error) {
disconnectError ??= error;
}
if (disconnectError) throw disconnectError;
},
getPoolStats: () => {
const { pool } = sharedPool.entry;
const total = pool.totalCount;
const idle = pool.idleCount;
return {
max: sharedPool.entry.maxConnections,
total,
active: Math.max(0, total - idle),
idle,
waiting: pool.waitingCount,
};
},
};
};