diff --git a/src/internal/database/client.ts b/src/internal/database/client.ts index 47e06057d..9b3c2ae70 100644 --- a/src/internal/database/client.ts +++ b/src/internal/database/client.ts @@ -42,6 +42,7 @@ async function getDbSettings( isMultitenant, databasePoolURL, databaseURL, + databaseEngine, databaseMaxConnections, requestXForwardedHostRegExp, } = getConfig() @@ -76,6 +77,7 @@ async function getDbSettings( return { dbUrl, + databaseEngine, isExternalPool, maxConnections, } diff --git a/src/internal/database/pg-connection.test.ts b/src/internal/database/pg-connection.test.ts index 063a88115..82626039c 100644 --- a/src/internal/database/pg-connection.test.ts +++ b/src/internal/database/pg-connection.test.ts @@ -7,6 +7,7 @@ import PgConnection from 'pg/lib/connection' import { vi } from 'vitest' import { getPgCancelConnectionTarget, + type PgExecutor, PgPoolExecutor, PgPoolStrategy, PgTenantConnection, @@ -533,6 +534,84 @@ describe('PgPoolExecutor', () => { }) describe('PgTransaction', () => { + it('applies a pending statement timeout before the first direct query', async () => { + const client = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release: vi.fn(), + } as unknown as PoolClient + const transaction = new PgTransaction(client) + + transaction.setPendingStatementTimeout(4321) + + await transaction.query('SELECT 1') + await transaction.query('SELECT 2') + + expect(client.query).toHaveBeenCalledTimes(3) + expect(client.query).toHaveBeenNthCalledWith( + 1, + "SELECT set_config('statement_timeout', $1, true)", + ['4321ms'] + ) + expect(client.query).toHaveBeenNthCalledWith(2, 'SELECT 1', undefined) + expect(client.query).toHaveBeenNthCalledWith(3, 'SELECT 2', undefined) + }) + + it('rejects a pre-aborted direct query before applying a pending statement timeout', async () => { + const client = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release: vi.fn(), + } as unknown as PoolClient + const transaction = new PgTransaction(client) + + transaction.setPendingStatementTimeout(4321) + + await expect( + transaction.query('SELECT 1', { signal: AbortSignal.abort() }) + ).rejects.toMatchObject({ + name: 'AbortError', + code: 'ABORT_ERR', + }) + expect(client.query).not.toHaveBeenCalled() + expect(client.release).toHaveBeenCalledWith(expect.objectContaining({ name: 'AbortError' })) + + await expect(transaction.query('SELECT 2')).rejects.toThrow( + 'Cannot query a completed transaction' + ) + expect(client.query).not.toHaveBeenCalled() + }) + + it('honors abort signals while applying a pending statement timeout', async () => { + const controller = new AbortController() + const client = Object.assign(new EventEmitter(), { + query: vi.fn(() => { + controller.abort() + return new Promise(() => undefined) + }), + release: vi.fn(), + }) as unknown as PoolClient & EventEmitter + const transaction = new PgTransaction(client) + + transaction.setPendingStatementTimeout(4321) + + await expect( + transaction.query('SELECT 1', { signal: controller.signal }) + ).rejects.toMatchObject({ + name: 'AbortError', + code: 'ABORT_ERR', + }) + + expect(client.query).toHaveBeenCalledTimes(1) + expect(client.query).toHaveBeenNthCalledWith( + 1, + "SELECT set_config('statement_timeout', $1, true)", + ['4321ms'] + ) + expect(client.release).toHaveBeenCalledWith(expect.objectContaining({ name: 'AbortError' })) + + await transaction.rollback() + expect(client.query).toHaveBeenCalledTimes(1) + }) + it('rejects queries after commit releases the client', async () => { const client = { query: vi.fn().mockResolvedValue({ rows: [] }), @@ -688,6 +767,209 @@ describe('PgTenantConnection', () => { }) }) + it('defers statement_timeout setup until the first scope application', async () => { + const query = vi.fn().mockResolvedValue({ rows: [] }) + const client = { + query, + release: vi.fn(), + } as unknown as PoolClient + const transaction = new PgTransaction(client) + const pool = { + acquire: vi.fn().mockReturnValue({ + beginTransaction: vi.fn().mockResolvedValue(transaction), + }), + } as unknown as PgPoolStrategy + const connection = new PgTenantConnection( + pool, + createPoolStrategySettings({ + isExternalPool: false, + }) + ) + + await expect(connection.transaction({ timeout: 4321 })).resolves.toBe(transaction) + expect(query).not.toHaveBeenCalled() + + await connection.setScope(transaction) + + expect(query).toHaveBeenCalledTimes(1) + const [statement, values] = query.mock.calls[0] + expect(statement).toContain("set_config('role', $1, true)") + expect(statement).toContain("set_config('statement_timeout', $10, true)") + expect(values).toEqual([ + 'authenticated', + 'authenticated', + 'jwt', + '', + JSON.stringify({ role: 'authenticated' }), + '{}', + '', + '', + '', + '4321ms', + ]) + }) + + it('keeps external-pool search_path setup before deferring statement_timeout', async () => { + const query = vi.fn().mockResolvedValue({ rows: [] }) + const client = { + query, + release: vi.fn(), + } as unknown as PoolClient + const transaction = new PgTransaction(client) + const pool = { + acquire: vi.fn().mockReturnValue({ + beginTransaction: vi.fn().mockResolvedValue(transaction), + }), + } as unknown as PgPoolStrategy + const connection = new PgTenantConnection( + pool, + createPoolStrategySettings({ + isExternalPool: true, + }) + ) + + await expect(connection.transaction({ timeout: 4321 })).resolves.toBe(transaction) + + expect(query).toHaveBeenCalledTimes(1) + expect(query).toHaveBeenNthCalledWith( + 1, + "SELECT set_config('search_path', $1, true)", + expect.any(Array) + ) + + await connection.setScope(transaction) + + expect(query).toHaveBeenCalledTimes(2) + const [scopeStatement, scopeValues] = query.mock.calls[1] + expect(scopeStatement).toContain("set_config('role', $1, true)") + expect(scopeStatement).toContain("set_config('statement_timeout', $10, true)") + expect(scopeValues).toEqual([ + 'authenticated', + 'authenticated', + 'jwt', + '', + JSON.stringify({ role: 'authenticated' }), + '{}', + '', + '', + '', + '4321ms', + ]) + }) + + it('uses a standalone statement_timeout setup for Multigres transactions', async () => { + const query = vi.fn().mockResolvedValue({ rows: [] }) + const client = { + query, + release: vi.fn(), + } as unknown as PoolClient + const transaction = new PgTransaction(client) + const pool = { + acquire: vi.fn().mockReturnValue({ + beginTransaction: vi.fn().mockResolvedValue(transaction), + }), + } as unknown as PgPoolStrategy + const connection = new PgTenantConnection( + pool, + createPoolStrategySettings({ + isExternalPool: true, + databaseEngine: 'multigres', + }) + ) + + await expect(connection.transaction({ timeout: 4321 })).resolves.toBe(transaction) + + expect(query).toHaveBeenCalledTimes(2) + expect(query).toHaveBeenNthCalledWith( + 1, + "SELECT set_config('search_path', $1, true)", + expect.any(Array) + ) + expect(query).toHaveBeenNthCalledWith(2, "SET LOCAL statement_timeout = '4321ms'", undefined) + + await connection.setScope(transaction) + + expect(query).toHaveBeenCalledTimes(4) + const [scopeStatement, scopeValues] = query.mock.calls[2] + expect(scopeStatement).not.toContain("set_config('statement_timeout'") + expect(scopeValues).toEqual([ + 'authenticated', + 'authenticated', + 'jwt', + '', + JSON.stringify({ role: 'authenticated' }), + '{}', + '', + '', + '', + ]) + expect(query).toHaveBeenNthCalledWith(4, "SET LOCAL statement_timeout = '4321ms'", undefined) + }) + + it('does not re-apply statement_timeout after setScope consumes it', async () => { + const query = vi.fn().mockResolvedValue({ rows: [] }) + const client = { + query, + release: vi.fn(), + } as unknown as PoolClient + const transaction = new PgTransaction(client) + const pool = { + acquire: vi.fn().mockReturnValue({ + beginTransaction: vi.fn().mockResolvedValue(transaction), + }), + } as unknown as PgPoolStrategy + const connection = new PgTenantConnection( + pool, + createPoolStrategySettings({ + isExternalPool: false, + }) + ) + + await connection.transaction({ timeout: 4321 }) + await connection.setScope(transaction) + await transaction.query('SELECT 1') + + expect(query).toHaveBeenCalledTimes(2) + expect(query).toHaveBeenNthCalledWith(2, 'SELECT 1', undefined) + expect( + query.mock.calls.filter(([statement]) => String(statement).includes('statement_timeout')) + ).toHaveLength(1) + }) + + it('reuses precomputed scope JSON payloads across repeated scope applications', async () => { + const pool = { + acquire: vi.fn(), + } as unknown as PgPoolStrategy + const connection = new PgTenantConnection( + pool, + createPoolStrategySettings({ + headers: { + 'x-test-header': 'test-value', + }, + user: { + jwt: 'jwt', + payload: { + role: 'authenticated', + sub: 'user-id', + }, + }, + }) + ) + const executor = { + query: vi.fn().mockResolvedValue({ rows: [] }), + } as unknown as PgExecutor + const stringifySpy = vi.spyOn(JSON, 'stringify') + + try { + await connection.setScope(executor) + await connection.setScope(executor) + + expect(stringifySpy).not.toHaveBeenCalled() + } finally { + stringifySpy.mockRestore() + } + }) + it('preserves setup errors when external-pool rollback fails', async () => { const setupError = new Error('search_path setup failed') const rollbackError = new Error('rollback failed') diff --git a/src/internal/database/pg-connection.ts b/src/internal/database/pg-connection.ts index b5ad8a170..b857d6a25 100644 --- a/src/internal/database/pg-connection.ts +++ b/src/internal/database/pg-connection.ts @@ -21,6 +21,7 @@ const { databaseMaxConnections, databasePoolDrainTimeout, databaseSSLRootCert, + databaseEngine, databaseStatementTimeout, } = getConfig() @@ -37,6 +38,37 @@ export interface PgQueryOptions { type PgQueryArgument = PgQueryOptions | unknown[] +// The first nine placeholders must stay in the same order as getScopeValues(). +// The timeout variant appends statement_timeout as $10. +const scopeConfigStatement = ` + SELECT + set_config('role', $1, true), + set_config('request.jwt.claim.role', $2, true), + set_config('request.jwt', $3, true), + set_config('request.jwt.claim.sub', $4, true), + set_config('request.jwt.claims', $5, true), + set_config('request.headers', $6, true), + set_config('request.method', $7, true), + set_config('request.path', $8, true), + set_config('storage.operation', $9, true), + set_config('storage.allow_delete_query', 'true', true); + ` + +const scopeConfigStatementWithTimeout = ` + SELECT + set_config('role', $1, true), + set_config('request.jwt.claim.role', $2, true), + set_config('request.jwt', $3, true), + set_config('request.jwt.claim.sub', $4, true), + set_config('request.jwt.claims', $5, true), + set_config('request.headers', $6, true), + set_config('request.method', $7, true), + set_config('request.path', $8, true), + set_config('storage.operation', $9, true), + set_config('storage.allow_delete_query', 'true', true), + set_config('statement_timeout', $10, true); + ` + export interface PgExecutor { query( statement: string | PgStatement, @@ -416,6 +448,8 @@ export class PgPoolExecutor implements PgTransactionalExecutor { export class PgTransaction implements PgExecutor { private completed = false + private pendingStatementTimeoutMs?: number + private appliedStatementTimeoutMs?: number constructor( private readonly client: PoolClient, @@ -426,6 +460,24 @@ export class PgTransaction implements PgExecutor { return this.completed } + setPendingStatementTimeout(timeoutMs: number | undefined): void { + this.pendingStatementTimeoutMs = timeoutMs && timeoutMs > 0 ? timeoutMs : undefined + } + + takePendingStatementTimeout(): number | undefined { + const timeoutMs = this.pendingStatementTimeoutMs + this.pendingStatementTimeoutMs = undefined + return timeoutMs + } + + setAppliedStatementTimeout(timeoutMs: number | undefined): void { + this.appliedStatementTimeoutMs = timeoutMs && timeoutMs > 0 ? timeoutMs : undefined + } + + getAppliedStatementTimeout(): number | undefined { + return this.appliedStatementTimeoutMs + } + async query( statement: string | PgStatement, options?: PgQueryArgument @@ -434,7 +486,16 @@ export class PgTransaction implements PgExecutor { throw new Error('Cannot query a completed transaction') } + const signal = getQuerySignal(options) + try { + assertValidSignal(signal) + + const pendingStatementTimeout = this.takePendingStatementTimeoutStatement() + if (pendingStatementTimeout) { + await runPgQuery(this.client, pendingStatementTimeout, { signal }) + } + const result = await runPgQuery(this.client, statement, options) this.clientErrorTracker?.throwIfErrored() return result @@ -499,6 +560,19 @@ export class PgTransaction implements PgExecutor { this.clientErrorTracker?.detach() } } + + private takePendingStatementTimeoutStatement(): PgStatement | undefined { + const timeoutMs = this.takePendingStatementTimeout() + + if (!timeoutMs) { + return undefined + } + + return { + text: `SELECT set_config('statement_timeout', $1, true)`, + values: [`${timeoutMs}ms`], + } + } } export class PgTenantConnection { @@ -506,12 +580,16 @@ export class PgTenantConnection { public readonly role: string private abortSignal?: AbortSignal private disposed = false + private readonly headersPayload: string + private readonly userPayload: string constructor( public readonly pool: PgPoolStrategy, protected readonly options: TenantConnectionOptions ) { this.role = options.user.payload.role || 'anon' + this.headersPayload = JSON.stringify(options.headers || {}) + this.userPayload = JSON.stringify(options.user.payload) } static stop() { @@ -608,16 +686,10 @@ export class PgTenantConnection { } const statementTimeout = opts?.timeout ?? databaseStatementTimeout - if (statementTimeout > 0) { - try { - await transaction.query({ - text: `SELECT set_config('statement_timeout', $1, true)`, - values: [`${statementTimeout}ms`], - }) - } catch (e) { - await this.rollbackTransactionSafely(transaction, e, 'statement_timeout setup') - throw e - } + if (this.isMultigresDatabase()) { + await this.applyStatementTimeoutImmediately(transaction, statementTimeout) + } else { + transaction.setPendingStatementTimeout(statementTimeout) } return transaction @@ -630,6 +702,27 @@ export class PgTenantConnection { } } + private isMultigresDatabase(): boolean { + return (this.options.databaseEngine ?? databaseEngine) === 'multigres' + } + + private async applyStatementTimeoutImmediately( + transaction: PgTransaction, + statementTimeout: number | undefined + ): Promise { + if (!statementTimeout || statementTimeout <= 0) { + return + } + + try { + await transaction.query(buildSetLocalStatementTimeoutStatement(statementTimeout)) + transaction.setAppliedStatementTimeout(statementTimeout) + } catch (e) { + await this.rollbackTransactionSafely(transaction, e, 'statement_timeout setup') + throw e + } + } + private assertNotDisposed(): void { if (this.disposed) { throw createDisposedTenantConnectionError() @@ -658,36 +751,50 @@ export class PgTenantConnection { } async setScope(tnx: PgExecutor) { - const headers = JSON.stringify(this.options.headers || {}) + const transaction = tnx instanceof PgTransaction ? tnx : undefined + const pendingStatementTimeout = transaction ? transaction.takePendingStatementTimeout() : 0 + const isMultigres = this.isMultigresDatabase() + const statementTimeout = isMultigres ? 0 : pendingStatementTimeout + const scopeValues = this.getScopeValues() + await tnx.query({ - text: ` - SELECT - set_config('role', $1, true), - set_config('request.jwt.claim.role', $2, true), - set_config('request.jwt', $3, true), - set_config('request.jwt.claim.sub', $4, true), - set_config('request.jwt.claims', $5, true), - set_config('request.headers', $6, true), - set_config('request.method', $7, true), - set_config('request.path', $8, true), - set_config('storage.operation', $9, true), - set_config('storage.allow_delete_query', 'true', true); - `, - values: [ - this.role, - this.role, - this.options.user.jwt || '', - this.options.user.payload.sub || '', - JSON.stringify(this.options.user.payload), - headers, - this.options.method || '', - this.options.path || '', - this.options.operation?.() || '', - ], + text: statementTimeout ? scopeConfigStatementWithTimeout : scopeConfigStatement, + values: statementTimeout ? [...scopeValues, `${statementTimeout}ms`] : scopeValues, }) + + if (isMultigres && transaction) { + // Multigres requires SET LOCAL for statement_timeout, and role scope setup resets it. + const timeoutToReapply = transaction.getAppliedStatementTimeout() + + if (timeoutToReapply && timeoutToReapply > 0) { + await transaction.query(buildSetLocalStatementTimeoutStatement(timeoutToReapply)) + } + } + } + + private getScopeValues(): unknown[] { + return [ + this.role, + this.role, + this.options.user.jwt || '', + this.options.user.payload.sub || '', + this.userPayload, + this.headersPayload, + this.options.method || '', + this.options.path || '', + this.options.operation?.() || '', + ] } } +function buildSetLocalStatementTimeoutStatement(statementTimeout: number): string { + if (!Number.isFinite(statementTimeout) || statementTimeout <= 0) { + throw new Error(`Invalid statement timeout: ${statementTimeout}`) + } + + return `SET LOCAL statement_timeout = '${statementTimeout}ms'` +} + function createDisposedTenantConnectionError(): Error { return new Error('Cannot use a disposed PgTenantConnection') } diff --git a/src/internal/database/pool.ts b/src/internal/database/pool.ts index 245778c5d..536fa16bd 100644 --- a/src/internal/database/pool.ts +++ b/src/internal/database/pool.ts @@ -10,7 +10,7 @@ import { recordCacheRequest, } from '@internal/monitoring/metrics' import { JWTPayload } from 'jose' -import { getConfig } from '../../config' +import { type DatabaseEngine, getConfig } from '../../config' const { isMultitenant, @@ -30,6 +30,7 @@ export interface TenantConnectionOptions { idleTimeoutMillis?: number reapIntervalMillis?: number maxConnections: number + databaseEngine?: DatabaseEngine clusterSize?: number numWorkers?: number user: User diff --git a/src/test/pg-connection.test.ts b/src/test/pg-connection.test.ts index eea82d4a2..b2f96e1ce 100644 --- a/src/test/pg-connection.test.ts +++ b/src/test/pg-connection.test.ts @@ -17,6 +17,7 @@ describe('Pg database foundation', () => { tenantId, isExternalPool: true, maxConnections: 2, + databaseEngine: getConfig().databaseEngine, dbUrl: databasePoolURL || databaseURL, user: superUser, superUser, @@ -127,7 +128,6 @@ describe('Pg database foundation', () => { request_path: string storage_operation: string allow_delete: string - statement_timeout: string }>({ text: ` SELECT @@ -135,10 +135,12 @@ describe('Pg database foundation', () => { current_setting('request.jwt.claim.role', true) as jwt_role, current_setting('request.path', true) as request_path, current_setting('storage.operation', true) as storage_operation, - current_setting('storage.allow_delete_query', true) as allow_delete, - current_setting('statement_timeout', true) as statement_timeout + current_setting('storage.allow_delete_query', true) as allow_delete `, }) + const timeout = await transaction.query<{ statement_timeout: string }>( + 'SHOW statement_timeout' + ) expect(result.rows[0]).toEqual( expect.objectContaining({ @@ -147,9 +149,9 @@ describe('Pg database foundation', () => { request_path: '/pg-foundation', storage_operation: 'pg-foundation-test', allow_delete: 'true', - statement_timeout: '1234ms', }) ) + expect(timeout.rows[0].statement_timeout).toBe('1234ms') await transaction.commit() } catch (e) { diff --git a/src/test/storage-pg-db.test.ts b/src/test/storage-pg-db.test.ts index 5d88b4127..75f9aa3bb 100644 --- a/src/test/storage-pg-db.test.ts +++ b/src/test/storage-pg-db.test.ts @@ -35,6 +35,7 @@ describe('StoragePgDB bucket metadata', () => { connectionSettings = { tenantId, dbUrl: databaseURL!, + databaseEngine: getConfig().databaseEngine, isExternalPool: false, maxConnections: 2, user: superUser, @@ -351,28 +352,7 @@ describe('StoragePgDB bucket metadata', () => { }) it('restores parent transaction scope after failed super-user queries', async () => { - const authenticatedUser = { - jwt: 'storage-pg-db-authenticated-jwt', - payload: { - role: 'authenticated', - sub: randomUUID(), - }, - } - const authenticatedSettings = { - ...connectionSettings, - user: authenticatedUser, - superUser, - } - const authenticatedPool = new PgPoolStrategy(authenticatedSettings) - const authenticatedDb = new StoragePgDB( - new PgTenantConnection(authenticatedPool, authenticatedSettings), - { - tenantId, - host: 'localhost', - } - ) - - try { + await withAuthenticatedDb('storage-pg-db-authenticated-jwt', async (authenticatedDb) => { await authenticatedDb.withTransaction(async (tx) => { await expect(readCurrentRole(tx)).resolves.toBe('authenticated') @@ -408,9 +388,62 @@ describe('StoragePgDB bucket metadata', () => { ) expect(result.rows[0].n).toBe(1) }) - } finally { - await authenticatedPool.destroy() - } + }) + }) + + it('preserves custom statement timeout after nested super-user scope restores', async () => { + await withAuthenticatedDb('storage-pg-db-timeout-jwt', async (authenticatedDb) => { + await authenticatedDb.withTransaction( + async (tx) => { + await expect(readCurrentRole(tx)).resolves.toBe('authenticated') + await expect(readCurrentStatementTimeout(tx)).resolves.toBe('4321ms') + + await expect( + runStorageQuery( + tx.asSuperUser() as StoragePgDB, + 'ReadSuperUserStatementTimeout', + async (pg) => { + await expect(readCurrentRoleFromExecutor(pg)).resolves.toBe(superUser.payload.role) + await expect(readCurrentStatementTimeoutFromExecutor(pg)).resolves.toBe('4321ms') + } + ) + ).resolves.toBeUndefined() + + await expect(readCurrentRole(tx)).resolves.toBe('authenticated') + await expect(readCurrentStatementTimeout(tx)).resolves.toBe('4321ms') + }, + { timeout: 4321 } + ) + }) + }) + + it('preserves custom statement timeout after failed nested super-user scope rolls back', async () => { + await withAuthenticatedDb('storage-pg-db-timeout-rollback-jwt', async (authenticatedDb) => { + await authenticatedDb.withTransaction( + async (tx) => { + await expect(readCurrentRole(tx)).resolves.toBe('authenticated') + await expect(readCurrentStatementTimeout(tx)).resolves.toBe('4321ms') + + await expect( + runStorageQuery( + tx.asSuperUser() as StoragePgDB, + 'FailSuperUserStatementTimeout', + async (pg) => { + await expect(readCurrentRoleFromExecutor(pg)).resolves.toBe(superUser.payload.role) + await expect(readCurrentStatementTimeoutFromExecutor(pg)).resolves.toBe('4321ms') + await pg.query("SET LOCAL statement_timeout = '30s'") + await expect(readCurrentStatementTimeoutFromExecutor(pg)).resolves.toBe('30s') + throw new Error('failed nested super-user timeout query') + } + ) + ).rejects.toThrow('failed nested super-user timeout query') + + await expect(readCurrentRole(tx)).resolves.toBe('authenticated') + await expect(readCurrentStatementTimeout(tx)).resolves.toBe('4321ms') + }, + { timeout: 4321 } + ) + }) }) it('preserves original errors when best-effort parent scope restoration fails', async () => { @@ -1747,6 +1780,50 @@ describe('StoragePgDB bucket metadata', () => { return result.rows[0].role } + function readCurrentStatementTimeout(storage: StoragePgDB): Promise { + return runStorageQuery(storage, 'ReadCurrentStatementTimeout', (pg) => + readCurrentStatementTimeoutFromExecutor(pg) + ) + } + + async function readCurrentStatementTimeoutFromExecutor(pg: PgExecutor): Promise { + const result = await pg.query<{ statement_timeout: string }>('SHOW statement_timeout') + + return result.rows[0].statement_timeout + } + + async function withAuthenticatedDb( + jwt: string, + fn: (authenticatedDb: StoragePgDB) => Promise + ): Promise { + const authenticatedUser = { + jwt, + payload: { + role: 'authenticated', + sub: randomUUID(), + }, + } + const authenticatedSettings = { + ...connectionSettings, + user: authenticatedUser, + superUser, + } + const authenticatedPool = new PgPoolStrategy(authenticatedSettings) + const authenticatedDb = new StoragePgDB( + new PgTenantConnection(authenticatedPool, authenticatedSettings), + { + tenantId, + host: 'localhost', + } + ) + + try { + await fn(authenticatedDb) + } finally { + await authenticatedPool.destroy() + } + } + async function tableExists(tableName: string): Promise { const result = await pool.acquire().query<{ exists: boolean }>({ text: `SELECT to_regclass($1) IS NOT NULL AS "exists"`,