diff --git a/docs/src/guide/adapters.md b/docs/src/guide/adapters.md index a9e11bb..12076d7 100644 --- a/docs/src/guide/adapters.md +++ b/docs/src/guide/adapters.md @@ -157,6 +157,13 @@ Optional methods provide optimized or specialized behavior: - `introspectModels`, `executeSchemaOperations`, `resetDatabase` - `createDatabaseFromError` - `readAppliedMigrationsState`, `writeAppliedMigrationsState` +- `dispose` + +Implement `dispose()` to release connection pools and clients. The CLI calls it +after a command finishes so short-lived processes (`migrate`, `seed`, …) exit +promptly instead of hanging on the pool's idle timeout. The built-in Kysely +adapter destroys its Kysely instance (ending the pool) and the Prisma +compatibility adapter calls `$disconnect()`. ## Query specifications diff --git a/src/adapters/KyselyDatabaseAdapter.ts b/src/adapters/KyselyDatabaseAdapter.ts index 58dcd5d..4486fd1 100644 --- a/src/adapters/KyselyDatabaseAdapter.ts +++ b/src/adapters/KyselyDatabaseAdapter.ts @@ -124,6 +124,17 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter { private readonly mapping: KyselyTableMapping = {}, ) {} + /** + * Destroys the underlying Kysely instance, which ends its connection pool so a + * short-lived process (e.g. the CLI) can exit promptly. A no-op when `db` is a + * transaction executor, which does not own the pool. + */ + public async dispose(): Promise { + const destroyable = this.db as { destroy?: () => Promise } + + if (typeof destroyable.destroy === 'function') await destroyable.destroy() + } + private resolveConfiguredDatabaseName(connectionString: string): string { const parsed = new URL(connectionString) const database = decodeURIComponent(parsed.pathname.replace(/^\/+/, '')) diff --git a/src/adapters/PrismaDatabaseAdapter.ts b/src/adapters/PrismaDatabaseAdapter.ts index b16601f..3637e1f 100644 --- a/src/adapters/PrismaDatabaseAdapter.ts +++ b/src/adapters/PrismaDatabaseAdapter.ts @@ -95,6 +95,16 @@ export class PrismaDatabaseAdapter implements DatabaseAdapter { } } + /** + * Disconnects the underlying Prisma client so a short-lived process (e.g. the + * CLI) can exit promptly instead of waiting for the connection to idle out. + */ + public async dispose(): Promise { + const disconnectable = this.prisma as { $disconnect?: () => Promise } + + if (typeof disconnectable.$disconnect === 'function') await disconnectable.$disconnect() + } + private hasTransactionSupport(client: PrismaClientLike): client is PrismaClientLike & { $transaction: ( callback: (transactionClient: PrismaClientLike) => TResult | Promise, diff --git a/src/cli/index.ts b/src/cli/index.ts index 9ddae9e..baca261 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -14,28 +14,34 @@ import { MigrateRollbackCommand } from './commands/MigrateRollbackCommand' import { MigrationHistoryCommand } from './commands/MigrationHistoryCommand' import { ModelsSyncCommand } from './commands/ModelsSyncCommand' import { SeedCommand } from './commands/SeedCommand' +import { disposeArkormRuntime } from '../helpers/runtime-config' import logo from './logo' const app = new CliApp() -await Kernel.init(app, { - logo, - name: 'Arkormˣ CLI', - baseCommands: [ - InitCommand, - DbCommand, - MakeModelCommand, - MakeFactoryCommand, - MakeSeederCommand, - MakeMigrationCommand, - ModelsSyncCommand, - SeedCommand, - MigrateCommand, - MigrateFreshCommand, - MigrateRollbackCommand, - MigrationHistoryCommand, - ], - exceptionHandler(exception) { - throw exception - }, -}) +try { + await Kernel.init(app, { + logo, + name: 'Arkormˣ CLI', + baseCommands: [ + InitCommand, + MakeModelCommand, + MakeFactoryCommand, + MakeSeederCommand, + MakeMigrationCommand, + ModelsSyncCommand, + SeedCommand, + MigrateCommand, + MigrateFreshCommand, + MigrateRollbackCommand, + MigrationHistoryCommand, + ], + exceptionHandler(exception) { + throw exception + }, + }) +} finally { + // Release database connections so the CLI exits promptly instead of hanging on + // the connection pool's idle timeout after migrate/seed/etc. finish. + await disposeArkormRuntime() +} diff --git a/src/helpers/migration-history.ts b/src/helpers/migration-history.ts index 37a9c70..4b8ee97 100644 --- a/src/helpers/migration-history.ts +++ b/src/helpers/migration-history.ts @@ -14,7 +14,7 @@ export const createEmptyAppliedMigrationsState = (): AppliedMigrationsState => ( export const supportsDatabaseMigrationState = ( adapter?: DatabaseAdapter, ): adapter is DatabaseAdapter & -Required> => { + Required> => { return ( typeof adapter?.readAppliedMigrationsState === 'function' && typeof adapter?.writeAppliedMigrationsState === 'function' @@ -64,13 +64,13 @@ export const readAppliedMigrationsState = (stateFilePath: string): AppliedMigrat }), runs: Array.isArray(parsed.runs) ? parsed.runs.filter((run): run is AppliedMigrationRun => { - return ( - typeof run?.id === 'string' && - typeof run?.appliedAt === 'string' && - Array.isArray(run?.migrationIds) && - run.migrationIds.every((item) => typeof item === 'string') - ) - }) + return ( + typeof run?.id === 'string' && + typeof run?.appliedAt === 'string' && + Array.isArray(run?.migrationIds) && + run.migrationIds.every((item) => typeof item === 'string') + ) + }) : [], } } catch { @@ -240,9 +240,9 @@ export const getLatestAppliedMigrations = ( * A "batch" is one `migrate` run. When no runs were recorded (e.g. legacy state * written before run tracking), each applied migration is treated as its own * batch so `--step` still behaves predictably. - * - * @param state - * @returns + * + * @param state + * @returns */ const resolveAppliedBatches = (state: AppliedMigrationsState): string[][] => { const runs = state.runs ?? [] @@ -270,10 +270,10 @@ const resolveAppliedBatches = (state: AppliedMigrationsState): string[][] => { * * Defaults to a single batch (`migrate:rollback` with no `--step`); `--step=N` * rolls back the last N batches. - * - * @param state - * @param batches - * @returns + * + * @param state + * @param batches + * @returns */ export const getLastBatchMigrations = ( state: AppliedMigrationsState, diff --git a/src/helpers/runtime-config.ts b/src/helpers/runtime-config.ts index 44c4ae9..de78db0 100644 --- a/src/helpers/runtime-config.ts +++ b/src/helpers/runtime-config.ts @@ -568,6 +568,50 @@ export const getRuntimeAdapter = (): DatabaseAdapter | undefined => { return runtimeAdapter } +/** + * Releases the database resources held by the configured runtime — the adapter's + * connection pool and/or the configured client. Intended for short-lived + * processes (the CLI) so the Node event loop drains and the process exits + * promptly instead of hanging on pool idle timeouts. Teardown failures are + * swallowed because the process is shutting down anyway. + */ +export const disposeArkormRuntime = async (): Promise => { + let adapterDisposed = false + + try { + if (runtimeAdapter && typeof runtimeAdapter.dispose === 'function') { + await runtimeAdapter.dispose() + adapterDisposed = true + } + } catch { + // Ignore adapter teardown failures. + } + + const client = getRuntimeClient() as + | { + $disconnect?: () => Promise + destroy?: () => Promise + end?: () => Promise + } + | undefined + if (!client) return + + try { + if (typeof client.$disconnect === 'function') { + // Prisma clients disconnect independently of any adapter. + await client.$disconnect() + } else if (!adapterDisposed && typeof client.destroy === 'function') { + // Kysely-style client — skip when an adapter already destroyed it. + await client.destroy() + } else if (!adapterDisposed && typeof client.end === 'function') { + // Bare connection pool (e.g. pg Pool). + await client.end() + } + } catch { + // Ignore client teardown failures. + } +} + export const getActiveTransactionClient = (): RuntimeClientLike | undefined => { return transactionClientStorage.getStore() } diff --git a/src/types/adapter.ts b/src/types/adapter.ts index 210c286..e6a3920 100644 --- a/src/types/adapter.ts +++ b/src/types/adapter.ts @@ -456,4 +456,10 @@ export interface DatabaseAdapter { callback: (adapter: DatabaseAdapter) => TResult | Promise, context?: AdapterTransactionContext, ) => Promise + /** + * Releases any resources the adapter holds (connection pools, clients). Called + * by short-lived processes such as the CLI so the event loop can drain and the + * process exits promptly instead of waiting for pool idle timeouts. + */ + dispose?: () => Promise } diff --git a/tests/base/migrate-database.spec.ts b/tests/base/migrate-database.spec.ts index 2cd8f7b..0b2e780 100644 --- a/tests/base/migrate-database.spec.ts +++ b/tests/base/migrate-database.spec.ts @@ -309,7 +309,9 @@ describe('database-backed migration command fallback', () => { await rollback.handle() expect(rollbackIo.errorLines).toHaveLength(0) - const droppedTables = adapter.executed.map((operations) => (operations[0] as { table: string }).table) + const droppedTables = adapter.executed.map( + (operations) => (operations[0] as { table: string }).table, + ) // down() runs in reverse application order: charlie, then bravo, then alpha. expect(droppedTables).toEqual(['charlie', 'bravo', 'alpha']) diff --git a/tests/base/runtime-teardown.spec.ts b/tests/base/runtime-teardown.spec.ts new file mode 100644 index 0000000..33e5241 --- /dev/null +++ b/tests/base/runtime-teardown.spec.ts @@ -0,0 +1,71 @@ +import { configureArkormRuntime, disposeArkormRuntime, resetArkormRuntimeForTests } from '../../src' +import { afterEach, describe, expect, it, vi } from 'vitest' + +afterEach(() => { + resetArkormRuntimeForTests() +}) + +describe('disposeArkormRuntime', () => { + it('disposes a configured adapter', async () => { + const dispose = vi.fn(async () => {}) + configureArkormRuntime(undefined, { adapter: { dispose } as never }) + + await disposeArkormRuntime() + + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it('disconnects a Prisma-style client', async () => { + const $disconnect = vi.fn(async () => {}) + configureArkormRuntime({ $disconnect } as never) + + await disposeArkormRuntime() + + expect($disconnect).toHaveBeenCalledTimes(1) + }) + + it('ends a bare connection-pool client', async () => { + const end = vi.fn(async () => {}) + configureArkormRuntime({ end } as never) + + await disposeArkormRuntime() + + expect(end).toHaveBeenCalledTimes(1) + }) + + it('does not double-close a client the adapter already owns', async () => { + const destroy = vi.fn(async () => {}) + const dispose = vi.fn(async () => {}) + // A Kysely-like client (has destroy) that the adapter already disposes. + configureArkormRuntime({ destroy } as never, { adapter: { dispose } as never }) + + await disposeArkormRuntime() + + expect(dispose).toHaveBeenCalledTimes(1) + expect(destroy).not.toHaveBeenCalled() + }) + + it('still disconnects a separate Prisma client after disposing the adapter', async () => { + const $disconnect = vi.fn(async () => {}) + const dispose = vi.fn(async () => {}) + configureArkormRuntime({ $disconnect } as never, { adapter: { dispose } as never }) + + await disposeArkormRuntime() + + expect(dispose).toHaveBeenCalledTimes(1) + expect($disconnect).toHaveBeenCalledTimes(1) + }) + + it('swallows teardown errors so the process can still exit', async () => { + const dispose = vi.fn(async () => { + throw new Error('boom') + }) + configureArkormRuntime(undefined, { adapter: { dispose } as never }) + + await expect(disposeArkormRuntime()).resolves.toBeUndefined() + }) + + it('is a no-op when nothing is configured', async () => { + await expect(disposeArkormRuntime()).resolves.toBeUndefined() + }) +}) diff --git a/tests/postgres/runtime-teardown.spec.ts b/tests/postgres/runtime-teardown.spec.ts new file mode 100644 index 0000000..e1a5f22 --- /dev/null +++ b/tests/postgres/runtime-teardown.spec.ts @@ -0,0 +1,46 @@ +import { Kysely, PostgresDialect } from 'kysely' +import { afterEach, describe, expect, it } from 'vitest' +import { + configureArkormRuntime, + createKyselyAdapter, + disposeArkormRuntime, + resetArkormRuntimeForTests, +} from '../../src' + +import { Pool } from 'pg' + +describe('runtime teardown releases the connection pool', () => { + afterEach(() => { + resetArkormRuntimeForTests() + }) + + const makeAdapter = () => { + const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + const db = new Kysely>({ dialect: new PostgresDialect({ pool }) }) + + return { adapter: createKyselyAdapter(db), pool } + } + + it('dispose() ends the Kysely pool so further queries fail', async () => { + const { adapter, pool } = makeAdapter() + + expect(await adapter.rawQuery!({ sql: 'select 1 as one' })).toEqual([{ one: 1 }]) + + await adapter.dispose!() + + // A destroyed pool rejects further use — the handle is released, letting the + // process exit instead of waiting on the pool's idle timeout. + expect(pool.ended).toBe(true) + await expect(adapter.rawQuery!({ sql: 'select 1' })).rejects.toBeTruthy() + }) + + it('disposeArkormRuntime() tears down the configured adapter end-to-end', async () => { + const { adapter, pool } = makeAdapter() + configureArkormRuntime(undefined, { adapter }) + + await adapter.rawQuery!({ sql: 'select 1' }) + await disposeArkormRuntime() + + expect(pool.ended).toBe(true) + }) +})