UNPKG

@spfn/core

Version:

SPFN Framework Core - File-based routing, transactions, repository pattern

885 lines (866 loc) 26.8 kB
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import postgres, { Sql } from 'postgres'; import * as drizzle_orm from 'drizzle-orm'; import { SQL } from 'drizzle-orm'; import * as drizzle_orm_pg_core from 'drizzle-orm/pg-core'; import { PgColumn, PgTable } from 'drizzle-orm/pg-core'; import * as hono from 'hono'; import { D as DatabaseError } from '../database-errors-BNNmLTJE.js'; /** * Database Configuration * * DB 연결 및 Connection Pool 설정 * * ✅ 구현 완료: * - 환경별 Connection Pool 설정 * - 재시도 설정 (Exponential Backoff) * - 환경변수 기반 설정 * * 🔗 관련 파일: * - src/server/core/db/connection.ts (연결 로직) * - src/server/core/db/index.ts (메인 export) */ interface DatabaseClients { /** Primary database for writes (or both read/write if no replica) */ write?: PostgresJsDatabase; /** Replica database for reads (optional, falls back to write) */ read?: PostgresJsDatabase; /** Raw postgres client for write operations (for cleanup) */ writeClient?: Sql; /** Raw postgres client for read operations (for cleanup) */ readClient?: Sql; } /** * Health check configuration */ interface HealthCheckConfig { enabled: boolean; interval: number; reconnect: boolean; maxRetries: number; retryInterval: number; } /** * Query performance monitoring configuration */ interface MonitoringConfig { enabled: boolean; slowThreshold: number; logQueries: boolean; } /** * Database initialization options */ interface DatabaseOptions { /** * Connection pool configuration * Overrides environment variables and defaults */ pool?: Partial<PoolConfig>; /** * Health check configuration * Periodic checks to ensure database connection is alive */ healthCheck?: Partial<HealthCheckConfig>; /** * Query performance monitoring configuration * Tracks slow queries and logs performance metrics */ monitoring?: Partial<MonitoringConfig>; } /** * Connection Pool 설정 */ interface PoolConfig { max: number; idleTimeout: number; } /** * 재시도 설정 */ interface RetryConfig { maxRetries: number; initialDelay: number; maxDelay: number; factor: number; } /** * Database factory with automatic environment variable detection * Supports: Single primary, Primary + Replica */ /** * Create database client(s) from environment variables * * Supported patterns (priority order): * 1. Single primary: DATABASE_URL * 2. Primary + Replica: DATABASE_WRITE_URL + DATABASE_READ_URL * 3. Legacy replica: DATABASE_URL + DATABASE_REPLICA_URL * * @param options - Optional database configuration (pool settings, etc.) * @returns Database client(s) or undefined if no configuration found * * @example * ```bash * # Single primary (most common) * DATABASE_URL=postgresql://localhost:5432/mydb * * # Primary + Replica * DATABASE_WRITE_URL=postgresql://primary:5432/mydb * DATABASE_READ_URL=postgresql://replica:5432/mydb * * # Legacy (backward compatibility) * DATABASE_URL=postgresql://primary:5432/mydb * DATABASE_REPLICA_URL=postgresql://replica:5432/mydb * ``` * * @example * ```typescript * // Custom pool configuration * const db = await createDatabaseFromEnv({ * pool: { max: 50, idleTimeout: 60 } * }); * ``` */ declare function createDatabaseFromEnv(options?: DatabaseOptions): Promise<DatabaseClients>; /** * Global Database instance manager * Provides singleton access to database across all modules * Supports Primary + Replica pattern with separate read/write instances */ /** * DB connection type */ type DbConnectionType = 'read' | 'write'; /** * Get global database write instance * * @returns Database write instance or undefined if not initialized * * @example * ```typescript * import { getDatabase } from '@spfn/core/db'; * * const db = getDatabase(); * if (db) { * const users = await db.select().from(usersTable); * } * ``` */ declare function getDatabase(type?: DbConnectionType): PostgresJsDatabase<Record<string, unknown>> | undefined; /** * Set global database instances (for testing or manual configuration) * * @param write - Database write instance * @param read - Database read instance (optional, defaults to write) * * @example * ```typescript * import { setDatabase } from '@spfn/core/db'; * import { drizzle } from 'drizzle-orm/postgres-js'; * import postgres from 'postgres'; * * const writeClient = postgres('postgresql://primary:5432/mydb'); * const readClient = postgres('postgresql://replica:5432/mydb'); * setDatabase(drizzle(writeClient), drizzle(readClient)); * ``` */ declare function setDatabase(write: PostgresJsDatabase<Record<string, unknown>> | undefined, read?: PostgresJsDatabase<Record<string, unknown>> | undefined): void; /** * Initialize database from environment variables * Automatically called by server startup * * Supported environment variables: * - DATABASE_URL (single primary) * - DATABASE_WRITE_URL + DATABASE_READ_URL (primary + replica) * - DATABASE_URL + DATABASE_REPLICA_URL (legacy replica) * - DB_POOL_MAX (connection pool max size) * - DB_POOL_IDLE_TIMEOUT (connection idle timeout in seconds) * - DB_HEALTH_CHECK_ENABLED (enable health checks, default: true) * - DB_HEALTH_CHECK_INTERVAL (health check interval in ms, default: 60000) * - DB_HEALTH_CHECK_RECONNECT (enable auto-reconnect, default: true) * - DB_HEALTH_CHECK_MAX_RETRIES (max reconnection attempts, default: 3) * - DB_HEALTH_CHECK_RETRY_INTERVAL (retry interval in ms, default: 5000) * - DB_MONITORING_ENABLED (enable query monitoring, default: true in dev, false in prod) * - DB_MONITORING_SLOW_THRESHOLD (slow query threshold in ms, default: 1000) * - DB_MONITORING_LOG_QUERIES (log actual SQL queries, default: false) * - DB_DEBUG_TRACE (enable detailed getDatabase() call tracing with caller info, default: false) * * Configuration priority: * 1. options parameter (ServerConfig) * 2. Environment variables * 3. Defaults (based on NODE_ENV) * * @param options - Optional database configuration (pool settings, etc.) * @returns Object with write and read instances * * @example * ```typescript * import { initDatabase } from '@spfn/core/db'; * * // Manual initialization (not needed if using server startup) * const { write, read } = await initDatabase(); * if (write) { * console.log('Database connected'); * } * ``` * * @example * ```typescript * // Custom pool configuration * const { write, read } = await initDatabase({ * pool: { max: 50, idleTimeout: 60 } * }); * ``` */ declare function initDatabase(options?: DatabaseOptions): Promise<{ write?: PostgresJsDatabase<Record<string, unknown>>; read?: PostgresJsDatabase<Record<string, unknown>>; }>; /** * Close all database connections and cleanup * * Properly closes postgres connection pools with timeout. * Should be called during graceful shutdown or after tests. * * @example * ```typescript * import { closeDatabase } from '@spfn/core/db'; * * // During graceful shutdown * process.on('SIGTERM', async () => { * await closeDatabase(); * process.exit(0); * }); * * // In tests * afterAll(async () => { * await closeDatabase(); * }); * ``` */ declare function closeDatabase(): Promise<void>; /** * Get database connection info (for debugging) */ declare function getDatabaseInfo(): { hasWrite: boolean; hasRead: boolean; isReplica: boolean; }; /** * Exponential Backoff로 DB 연결 생성 * * @param connectionString - PostgreSQL 연결 문자열 * @param poolConfig - Connection Pool 설정 * @param retryConfig - 재시도 설정 * @returns PostgreSQL 클라이언트 */ declare function createDatabaseConnection(connectionString: string, poolConfig: PoolConfig, retryConfig: RetryConfig): Promise<postgres.Sql<{}>>; /** * DB 연결 상태 확인 * * @param client - PostgreSQL 클라이언트 * @returns 연결 가능 여부 */ declare function checkConnection(client: Sql): Promise<boolean>; /** * Drizzle Kit configuration generator * Automatically generates drizzle.config.ts from environment variables */ interface DrizzleConfigOptions { /** Database connection URL (defaults to process.env.DATABASE_URL) */ databaseUrl?: string; /** Schema files glob pattern or array of patterns (defaults to './src/server/entities/\*\*\/*.ts') */ schema?: string | string[]; /** Migration output directory (defaults to './src/server/drizzle') */ out?: string; /** Database dialect (auto-detected from URL if not provided) */ dialect?: 'postgresql' | 'mysql' | 'sqlite'; /** Current working directory for discovering package schemas */ cwd?: string; /** Disable automatic package schema discovery */ disablePackageDiscovery?: boolean; /** Only include schemas from specific package (e.g., '@spfn/cms') */ packageFilter?: string; } /** * Detect database dialect from connection URL */ declare function detectDialect(url: string): 'postgresql' | 'mysql' | 'sqlite'; /** * Generate Drizzle Kit configuration * * @param options - Configuration options * @returns Drizzle Kit configuration object * * @example * ```ts * // Zero-config (reads from process.env.DATABASE_URL) * const config = getDrizzleConfig(); * * // Custom config * const config = getDrizzleConfig({ * databaseUrl: 'postgresql://localhost/mydb', * schema: './src/db/schema/*.ts', * out: './migrations', * }); * ``` */ declare function getDrizzleConfig(options?: DrizzleConfigOptions): { schema: string | string[]; out: string; dialect: "postgresql" | "mysql" | "sqlite"; dbCredentials: { url: string; }; }; /** * Generate drizzle.config.ts file content * * @param options - Configuration options * @returns File content as string */ declare function generateDrizzleConfigFile(options?: DrizzleConfigOptions): string; /** * Standard auto-incrementing primary key * * @returns bigserial primary key column * * @example * ```typescript * export const users = pgTable('users', { * id: id(), * // ... * }); * ``` */ declare function id(): drizzle_orm.IsPrimaryKey<drizzle_orm.NotNull<drizzle_orm_pg_core.PgBigSerial53BuilderInitial<"id">>>; /** * Standard timestamp fields (createdAt, updatedAt) * * Both fields are timezone-aware, auto-set to current time on creation. * When autoUpdate is enabled, updatedAt will be automatically updated on record updates. * * @param options - Optional configuration * @param options.autoUpdate - Automatically update updatedAt on record updates (default: false) * @returns Object with createdAt and updatedAt columns * * @example * ```typescript * // Without auto-update * export const users = pgTable('users', { * id: id(), * email: text('email'), * ...timestamps(), * }); * * // With auto-update * export const posts = pgTable('posts', { * id: id(), * title: text('title'), * ...timestamps({ autoUpdate: true }), * }); * ``` */ declare function timestamps(options?: { autoUpdate?: boolean; }): { createdAt: drizzle_orm.NotNull<drizzle_orm.HasDefault<drizzle_orm_pg_core.PgTimestampBuilderInitial<"created_at">>>; updatedAt: drizzle_orm.NotNull<drizzle_orm.HasDefault<drizzle_orm_pg_core.PgTimestampBuilderInitial<"updated_at">>>; }; /** * Foreign key reference to another table * * Creates a bigserial column with cascade delete. * Type-safe: ensures the reference points to a valid PostgreSQL column. * * @param name - Column name (e.g., 'author' creates 'author_id') * @param reference - Reference to parent table column * @param options - Optional foreign key options * * @example * ```typescript * import { users } from './users'; * * export const posts = pgTable('posts', { * id: id(), * authorId: foreignKey('author', () => users.id), * ...timestamps(), * }); * ``` */ declare function foreignKey<T extends PgColumn>(name: string, reference: () => T, options?: { onDelete?: 'cascade' | 'set null' | 'restrict' | 'no action'; }): drizzle_orm.NotNull<drizzle_orm_pg_core.PgBigSerial53BuilderInitial<`${string}_id`>>; /** * Optional foreign key reference (nullable) * * Type-safe: ensures the reference points to a valid PostgreSQL column. * * @param name - Column name (e.g., 'author' creates 'author_id') * @param reference - Reference to parent table column * @param options - Optional foreign key options * * @example * ```typescript * export const posts = pgTable('posts', { * id: id(), * authorId: optionalForeignKey('author', () => users.id), * }); * ``` */ declare function optionalForeignKey<T extends PgColumn>(name: string, reference: () => T, options?: { onDelete?: 'cascade' | 'set null' | 'restrict' | 'no action'; }): drizzle_orm_pg_core.PgBigSerial53BuilderInitial<`${string}_id`>; /** * Database Schema Helper * * Provides utilities for creating isolated PostgreSQL schemas for SPFN functions */ /** * Create a namespaced PostgreSQL schema for a function * * @param packageName - NPM package name (e.g., '@spfn/cms', 'spfn-auth') * @returns PostgreSQL schema object for creating tables * * @example * ```typescript * // @spfn/cms → spfn_cms schema * import { createFunctionSchema } from '@spfn/core/db'; * * const schema = createFunctionSchema('@spfn/cms'); * * export const labels = schema.table('labels', { * id: id(), * name: text('name').notNull(), * }); * // Creates table: spfn_cms.labels * ``` */ declare function createFunctionSchema(packageName: string): drizzle_orm_pg_core.PgSchema<string>; /** * Convert package name to PostgreSQL schema name * * @param packageName - NPM package name * @returns Schema name in PostgreSQL format * * @example * ```typescript * packageNameToSchema('@spfn/cms') // 'spfn_cms' * packageNameToSchema('@company/spfn-auth') // 'company_spfn_auth' * packageNameToSchema('spfn-storage') // 'spfn_storage' * ``` */ declare function packageNameToSchema(packageName: string): string; /** * Get recommended schema name for a package * * @param packageName - NPM package name * @returns Object with schema name and whether it's scoped * * @example * ```typescript * getSchemaInfo('@spfn/cms') * // { schemaName: 'spfn_cms', isScoped: true, scope: 'spfn' } * * getSchemaInfo('spfn-auth') * // { schemaName: 'spfn_auth', isScoped: false, scope: null } * ``` */ declare function getSchemaInfo(packageName: string): { schemaName: string; isScoped: boolean; scope: string | null; }; /** * AsyncLocalStorage-based Transaction Context * * Uses Node.js AsyncLocalStorage to propagate transactions throughout the async call chain. * * Features: * - AsyncLocalStorage-based context management * - Type-safe transaction propagation across async chains * - Transaction ID tracking for debugging and tracing * - Nested transaction detection and logging * - Transaction nesting level tracking */ /** * Transaction database type * Uses Record<string, unknown> to accept any schema shape */ type TransactionDB = PostgresJsDatabase<Record<string, unknown>>; /** * Transaction context stored in AsyncLocalStorage */ type TransactionContext = { /** The actual Drizzle transaction object */ tx: TransactionDB; /** Unique transaction ID for logging and tracing */ txId: string; level: number; }; /** * Get current transaction from AsyncLocalStorage * * @returns Transaction if available, null otherwise */ declare function getTransaction(): TransactionDB | null; /** * Run a function within a transaction context * * The transaction will be available to all async operations within the callback * via getTransaction(). * * @param tx - Drizzle transaction object * @param txId - Unique ID for the transaction * @param callback - Function to run within transaction context * @returns Result of the callback */ declare function runWithTransaction<T>(tx: TransactionDB, txId: string, // Add txId parameter callback: () => Promise<T>): Promise<T>; /** * Transaction middleware options */ interface TransactionalOptions { /** * Slow transaction warning threshold in milliseconds * @default 1000 (1 second) */ slowThreshold?: number; /** * Enable transaction logging * @default true */ enableLogging?: boolean; /** * Transaction timeout in milliseconds * * If transaction exceeds this duration, it will be aborted with TransactionError. * * @default 30000 (30 seconds) or TRANSACTION_TIMEOUT environment variable * * @example * ```typescript * // Default timeout (30s or TRANSACTION_TIMEOUT env var) * Transactional() * * // Custom timeout for specific route (60s) * Transactional({ timeout: 60000 }) * * // Disable timeout * Transactional({ timeout: 0 }) * ``` */ timeout?: number; } /** * Transaction middleware for Hono routes * * Automatically wraps route handlers in a database transaction. * Commits on success, rolls back on error. * * @example * ```typescript * // In your route file * export const middlewares = [Transactional()]; * * export async function POST(c: RouteContext) { * // All DB operations run in a transaction * const [user] = await db.insert(users).values(body).returning(); * await db.insert(profiles).values({ userId: user.id }); * // Auto-commits on success * return c.json(user, 201); * } * ``` * * @example * ```typescript * // With custom options * export const middlewares = [ * Transactional({ * slowThreshold: 2000, // Warn if transaction takes > 2s * enableLogging: false, // Disable logging * timeout: 60000, // 60 second timeout for long operations * }) * ]; * ``` * * 🔄 Transaction behavior: * - Success: Auto-commit * - Error: Auto-rollback * - Detects context.error to trigger rollback * * 📊 Transaction logging: * - Auto-logs transaction start/commit/rollback * - Measures and records execution time * - Warns about slow transactions (default: > 1s) */ declare function Transactional(options?: TransactionalOptions): hono.MiddlewareHandler<any, string, {}>; /** * PostgreSQL Error Conversion Utilities * * Converts PostgreSQL-specific error codes to custom error types * @see https://www.postgresql.org/docs/current/errcodes-appendix.html */ /** * Convert PostgreSQL error to custom DatabaseError * * Maps PostgreSQL error codes to appropriate error classes with correct status codes * * @param error - PostgreSQL error object (from pg driver or Drizzle) * @returns Custom DatabaseError instance * * @example * ```typescript * import { fromPostgresError } from '@spfn/core/db'; * * try { * await db.insert(users).values(data); * } catch (pgError) { * throw fromPostgresError(pgError); * } * ``` */ declare function fromPostgresError(error: any): DatabaseError; /** * Database Helper Functions * * Type-safe helper functions for common database operations. * Automatically handles: * - Transaction context detection * - Read/Write database separation * - Type inference from table schema * * @example * ```typescript * // Simple object-based where * const user = await findOne(users, { id: 1 }); * const labels = await findMany(cmsLabels, { section: 'hero' }); * * // Complex SQL-based where * const user = await findOne(users, and(eq(users.id, 1), gt(users.age, 18))); * const labels = await findMany(cmsLabels, { * where: or(like(cmsLabels.key, 'hero.%'), eq(cmsLabels.section, 'footer')), * limit: 10 * }); * ``` */ /** * Infer SELECT model from PgTable */ type InferSelectModel<T extends PgTable> = T['$inferSelect']; /** * Infer INSERT model from PgTable */ type InferInsertModel<T extends PgTable> = T['$inferInsert']; /** * Object-based where condition (AND only, equality only) */ type WhereObject<T> = { [K in keyof T]?: T[K]; }; /** * Find a single record * * @param table - Drizzle table schema * @param where - Object or SQL condition * @returns Single record or null * * @example * ```typescript * // Object-based * const user = await findOne(users, { id: 1 }); * const label = await findOne(cmsLabels, { key: 'hero.title', section: 'hero' }); * * // SQL-based * const user = await findOne(users, and(eq(users.id, 1), gt(users.age, 18))); * ``` */ declare function findOne<T extends PgTable>(table: T, where: WhereObject<InferSelectModel<T>>): Promise<InferSelectModel<T> | null>; declare function findOne<T extends PgTable>(table: T, where: SQL | undefined): Promise<InferSelectModel<T> | null>; /** * Find multiple records * * @param table - Drizzle table schema * @param options - Query options (where, orderBy, limit, offset) * @returns Array of records * * @example * ```typescript * // Simple object where * const labels = await findMany(cmsLabels, { section: 'hero' }); * * // With options * const labels = await findMany(cmsLabels, { * where: { section: 'hero' }, * orderBy: desc(cmsLabels.updatedAt), * limit: 10, * offset: 0 * }); * * // Complex SQL where * const labels = await findMany(cmsLabels, { * where: and( * like(cmsLabels.key, 'hero.%'), * eq(cmsLabels.section, 'hero') * ), * limit: 10 * }); * ``` */ declare function findMany<T extends PgTable>(table: T, options?: { where?: WhereObject<InferSelectModel<T>> | SQL | undefined; orderBy?: SQL | SQL[]; limit?: number; offset?: number; }): Promise<InferSelectModel<T>[]>; /** * Create a new record * * @param table - Drizzle table schema * @param data - Insert data * @returns Created record * * @example * ```typescript * const user = await create(users, { * email: 'test@example.com', * name: 'Test User' * }); * ``` */ declare function create<T extends PgTable>(table: T, data: InferInsertModel<T>): Promise<InferSelectModel<T>>; /** * Create multiple records * * @param table - Drizzle table schema * @param data - Array of insert data * @returns Array of created records * * @example * ```typescript * const users = await createMany(users, [ * { email: 'user1@example.com', name: 'User 1' }, * { email: 'user2@example.com', name: 'User 2' } * ]); * ``` */ declare function createMany<T extends PgTable>(table: T, data: InferInsertModel<T>[]): Promise<InferSelectModel<T>[]>; /** * Upsert a record (INSERT or UPDATE on conflict) * * @param table - Drizzle table schema * @param data - Insert data * @param options - Conflict resolution options * @returns Upserted record * * @example * ```typescript * // Basic upsert * const cache = await upsert(cmsPublishedCache, { * section: 'home', * locale: 'ko', * content: {...} * }, { * target: [cmsPublishedCache.section, cmsPublishedCache.locale], * set: { * content: data.content, * updatedAt: new Date() * } * }); * * // With SQL expression * const cache = await upsert(cmsPublishedCache, data, { * target: [cmsPublishedCache.section, cmsPublishedCache.locale], * set: { * content: data.content, * version: sql`${cmsPublishedCache.version} + 1` * } * }); * ``` */ declare function upsert<T extends PgTable>(table: T, data: InferInsertModel<T>, options: { target: PgColumn[]; set?: Partial<InferInsertModel<T>> | Record<string, SQL | any>; }): Promise<InferSelectModel<T>>; /** * Update a single record * * @param table - Drizzle table schema * @param where - Object or SQL condition * @param data - Update data * @returns Updated record or null * * @example * ```typescript * // Object-based where * const user = await updateOne(users, { id: 1 }, { name: 'Updated Name' }); * * // SQL-based where * const user = await updateOne(users, eq(users.id, 1), { name: 'Updated Name' }); * ``` */ declare function updateOne<T extends PgTable>(table: T, where: WhereObject<InferSelectModel<T>> | SQL | undefined, data: Partial<InferInsertModel<T>>): Promise<InferSelectModel<T> | null>; /** * Update multiple records * * @param table - Drizzle table schema * @param where - Object or SQL condition * @param data - Update data * @returns Array of updated records * * @example * ```typescript * const users = await updateMany(users, * { role: 'user' }, * { verified: true } * ); * ``` */ declare function updateMany<T extends PgTable>(table: T, where: WhereObject<InferSelectModel<T>> | SQL | undefined, data: Partial<InferInsertModel<T>>): Promise<InferSelectModel<T>[]>; /** * Delete a single record * * @param table - Drizzle table schema * @param where - Object or SQL condition * @returns Deleted record or null * * @example * ```typescript * // Object-based where * const user = await deleteOne(users, { id: 1 }); * * // SQL-based where * const user = await deleteOne(users, eq(users.id, 1)); * ``` */ declare function deleteOne<T extends PgTable>(table: T, where: WhereObject<InferSelectModel<T>> | SQL | undefined): Promise<InferSelectModel<T> | null>; /** * Delete multiple records * * @param table - Drizzle table schema * @param where - Object or SQL condition * @returns Array of deleted records * * @example * ```typescript * const users = await deleteMany(users, { verified: false }); * ``` */ declare function deleteMany<T extends PgTable>(table: T, where: WhereObject<InferSelectModel<T>> | SQL | undefined): Promise<InferSelectModel<T>[]>; /** * Count records * * @param table - Drizzle table schema * @param where - Optional object or SQL condition * @returns Number of records * * @example * ```typescript * const total = await count(users); * const activeUsers = await count(users, { active: true }); * const adults = await count(users, gt(users.age, 18)); * ``` */ declare function count<T extends PgTable>(table: T, where?: WhereObject<InferSelectModel<T>> | SQL | undefined): Promise<number>; export { type DatabaseClients, type DrizzleConfigOptions, type PoolConfig, type RetryConfig, type TransactionContext, type TransactionDB, Transactional, type TransactionalOptions, checkConnection, closeDatabase, count, create, createDatabaseConnection, createDatabaseFromEnv, createFunctionSchema, createMany, deleteMany, deleteOne, detectDialect, findMany, findOne, foreignKey, fromPostgresError, generateDrizzleConfigFile, getDatabase, getDatabaseInfo, getDrizzleConfig, getSchemaInfo, getTransaction, id, initDatabase, optionalForeignKey, packageNameToSchema, runWithTransaction, setDatabase, timestamps, updateMany, updateOne, upsert };