Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/src/guide/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions src/adapters/KyselyDatabaseAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const destroyable = this.db as { destroy?: () => Promise<void> }

if (typeof destroyable.destroy === 'function') await destroyable.destroy()
}

private resolveConfiguredDatabaseName(connectionString: string): string {
const parsed = new URL(connectionString)
const database = decodeURIComponent(parsed.pathname.replace(/^\/+/, ''))
Expand Down
10 changes: 10 additions & 0 deletions src/adapters/PrismaDatabaseAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const disconnectable = this.prisma as { $disconnect?: () => Promise<void> }

if (typeof disconnectable.$disconnect === 'function') await disconnectable.$disconnect()
}

private hasTransactionSupport(client: PrismaClientLike): client is PrismaClientLike & {
$transaction: <TResult>(
callback: (transactionClient: PrismaClientLike) => TResult | Promise<TResult>,
Expand Down
48 changes: 27 additions & 21 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

import { CliApp } from './CliApp'
import { DbCommand } from './commands/DbCommand'

Check warning on line 4 in src/cli/index.ts

View workflow job for this annotation

GitHub Actions / Run Tests (25.x)

'DbCommand' is defined but never used. Allowed unused vars must match /^I[A-Z]|^_/u

Check warning on line 4 in src/cli/index.ts

View workflow job for this annotation

GitHub Actions / Run Tests (20.x)

'DbCommand' is defined but never used. Allowed unused vars must match /^I[A-Z]|^_/u

Check warning on line 4 in src/cli/index.ts

View workflow job for this annotation

GitHub Actions / Run Tests (22.x)

'DbCommand' is defined but never used. Allowed unused vars must match /^I[A-Z]|^_/u

Check warning on line 4 in src/cli/index.ts

View workflow job for this annotation

GitHub Actions / Run Tests (24.x)

'DbCommand' is defined but never used. Allowed unused vars must match /^I[A-Z]|^_/u
import { InitCommand } from './commands/InitCommand'
import { Kernel } from '@h3ravel/musket'
import { MakeFactoryCommand } from './commands/MakeFactoryCommand'
Expand All @@ -14,28 +14,34 @@
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()
}
30 changes: 15 additions & 15 deletions src/helpers/migration-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const createEmptyAppliedMigrationsState = (): AppliedMigrationsState => (
export const supportsDatabaseMigrationState = (
adapter?: DatabaseAdapter,
): adapter is DatabaseAdapter &
Required<Pick<DatabaseAdapter, 'readAppliedMigrationsState' | 'writeAppliedMigrationsState'>> => {
Required<Pick<DatabaseAdapter, 'readAppliedMigrationsState' | 'writeAppliedMigrationsState'>> => {
return (
typeof adapter?.readAppliedMigrationsState === 'function' &&
typeof adapter?.writeAppliedMigrationsState === 'function'
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ?? []
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions src/helpers/runtime-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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<void>
destroy?: () => Promise<void>
end?: () => Promise<void>
}
| 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()
}
Expand Down
6 changes: 6 additions & 0 deletions src/types/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,4 +456,10 @@ export interface DatabaseAdapter {
callback: (adapter: DatabaseAdapter) => TResult | Promise<TResult>,
context?: AdapterTransactionContext,
) => Promise<TResult>
/**
* 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<void>
}
4 changes: 3 additions & 1 deletion tests/base/migrate-database.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
71 changes: 71 additions & 0 deletions tests/base/runtime-teardown.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
46 changes: 46 additions & 0 deletions tests/postgres/runtime-teardown.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, never>>({ 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)
})
})
Loading