diff --git a/packages/database/prod/src/main.test.ts b/packages/database/prod/src/main.test.ts index 81c7310f9..f13dcf190 100644 --- a/packages/database/prod/src/main.test.ts +++ b/packages/database/prod/src/main.test.ts @@ -93,7 +93,7 @@ describe('getDatabase', () => { const result = getDatabase() - expect(mockWaddlerNeonHttp).toHaveBeenCalledWith({ client: 'neon-http-client' }) + expect(mockWaddlerNeonHttp).toHaveBeenCalledWith({ client: expect.any(Function) }) expect(mockNeonPool).toHaveBeenCalledWith({ connectionString: CONNECTION_STRING }) expect(mockWaddlerNodePostgres).not.toHaveBeenCalled() expect(mockPgPool).not.toHaveBeenCalled() @@ -102,7 +102,7 @@ describe('getDatabase', () => { driver: 'serverless', sql: 'neon-http-sql', pool: expect.any(Object), - httpClient: 'neon-http-client', + httpClient: expect.any(Function), connectionString: CONNECTION_STRING, }) }) @@ -152,6 +152,34 @@ describe('getDatabase', () => { expect(mockPgPool).toHaveBeenCalledWith({ connectionString: CONNECTION_STRING }) expect(result.connectionString).toBe(CONNECTION_STRING) }) + + it('re-resolves NETLIFY_DB_URL on use so rotated credentials take effect', () => { + process.env.NETLIFY_DB_URL = CONNECTION_STRING + process.env.NETLIFY_DB_DRIVER = 'serverless' + // Return a callable (with the connection string attached) so the httpClient + // Proxy can be invoked. + mockNeon.mockImplementation((connectionString: string) => Object.assign(() => undefined, { connectionString })) + + // A connection obtained once, as with the common module-scope pattern. + const db = getDatabase() + expect(db.connectionString).toBe(CONNECTION_STRING) + expect(mockNeon).toHaveBeenLastCalledWith(CONNECTION_STRING) + + // Credentials rotate: NETLIFY_DB_URL is refreshed with a new value. + process.env.NETLIFY_DB_URL = OTHER_CONNECTION_STRING + + // The live connection string reflects the rotation immediately... + expect(db.connectionString).toBe(OTHER_CONNECTION_STRING) + + // ...and the next query rebuilds the underlying client for the new + // credentials instead of reusing the stale one. + expect(db.driver).toBe('serverless') + if (db.driver !== 'serverless') + return // The real call signature is tagged-template only; cast to invoke it with a + // plain string in the test, which is all the apply trap needs. + ;(db.httpClient as unknown as (query: string) => unknown)('select 1') + expect(mockNeon).toHaveBeenLastCalledWith(OTHER_CONNECTION_STRING) + }) }) describe('getConnectionString', () => { diff --git a/packages/database/prod/src/main.ts b/packages/database/prod/src/main.ts index 40099604b..9e5ead1d1 100644 --- a/packages/database/prod/src/main.ts +++ b/packages/database/prod/src/main.ts @@ -1,5 +1,4 @@ import { neon, neonConfig, Pool as NeonPool } from '@neondatabase/serverless' -import type { NeonQueryFunction } from '@neondatabase/serverless' import ws from 'ws' import pg from 'pg' import type { SQL } from 'waddler' @@ -10,6 +9,8 @@ import { getEnvironment } from '@netlify/runtime-utils' import { MissingDatabaseConnectionError } from './environment.js' +type NeonHttpClient = ReturnType + export interface GetDatabaseOptions { connectionString?: string debug?: boolean @@ -26,11 +27,7 @@ export interface ServerlessDatabaseConnection { driver: 'serverless' sql: SQL pool: NeonPool - // Using because neon() returns NeonQueryFunction, - // which is incompatible with drizzle-orm's NeonHttpClient (NeonQueryFunction) - // due to contravariance in the transaction method's generic parameters. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - httpClient: NeonQueryFunction + httpClient: NeonHttpClient connectionString: string } @@ -47,44 +44,113 @@ export function getConnectionString(): string { return connectionString } +function ensureNeonWebSocket(): void { + // We can remove this, and the dependency on `ws`, once we stop supporting + // Node.js 22. + /* eslint-disable n/no-unsupported-features/node-builtins */ + if (!neonConfig.webSocketConstructor && typeof WebSocket === 'undefined') { + neonConfig.webSocketConstructor = ws + } + /* eslint-enable n/no-unsupported-features/node-builtins */ +} + +// Returns an HTTP client that lazily loads the connection string from the +// environment and, when it changes, rebuilds the underlying Neon client. +// +// A Proxy is used because the client is both callable (tagged-template queries) +// and carries methods (`query`, `transaction`, `unsafe`). +function createRefreshingHttpClient(readConnectionString: () => string): NeonHttpClient { + let cachedConnectionString: string | undefined + let cachedClient: NeonHttpClient | undefined + + const getClient = (): NeonHttpClient => { + const connectionString = readConnectionString() + + if (connectionString !== cachedConnectionString || cachedClient === undefined) { + cachedConnectionString = connectionString + cachedClient = neon(connectionString) + } + + return cachedClient + } + + // Construct eagerly so getDatabase() fails fast and the client is warm. + getClient() + + // The Proxy target is a throwaway callable — it only has to be a callable + // object for the traps to attach. + const target = (() => undefined) as unknown as NeonHttpClient + + return new Proxy(target, { + apply: (_target, _thisArg, args: unknown[]) => (getClient() as (...callArgs: unknown[]) => unknown)(...args), + get: (_target, prop, receiver) => { + const value: unknown = Reflect.get(getClient(), prop, receiver) + + // Data property: nothing to defer, hand back the value as-is. + if (typeof value !== 'function') { + return value + } + + // Method: return a wrapper so the call is deferred to a freshly-resolved + // client. This is what makes a captured method reference use the updated + // credentials after rotation. + return (...args: unknown[]): unknown => { + const fn = Reflect.get(getClient(), prop, receiver) as (...callArgs: unknown[]) => unknown + + return fn.apply(getClient(), args) + } + }, + }) +} + export function getDatabase(options: GetDatabaseOptions = {}): DatabaseConnection { const env = getEnvironment() - const connectionString = options.connectionString ?? env.get('NETLIFY_DB_URL') + const { connectionString: override } = options - if (!connectionString) { - throw new MissingDatabaseConnectionError() + const readConnectionString = (): string => { + const connectionString = override ?? env.get('NETLIFY_DB_URL') + if (!connectionString) { + throw new MissingDatabaseConnectionError() + } + + return connectionString } + const connectionString = readConnectionString() const driver = env.get('NETLIFY_DB_DRIVER') if (driver === 'serverless') { - // We can remove this, and the dependency on `ws`, once we stop supporting - // Node.js 22. - /* eslint-disable n/no-unsupported-features/node-builtins */ - if (!neonConfig.webSocketConstructor && typeof WebSocket === 'undefined') { - neonConfig.webSocketConstructor = ws - } - /* eslint-enable n/no-unsupported-features/node-builtins */ + ensureNeonWebSocket() + + const httpClient = createRefreshingHttpClient(readConnectionString) - const httpClient = neon(connectionString) + // Unlike the stateless HTTP client, a pool holds live, already-authenticated + // connections, so it can't transparently swap credentials, as that would + // mean tearing down the pool and interrupting in-flight queries. It stays + // pinned to the connection string at construction. + const pool = new NeonPool({ connectionString }) return { driver: 'serverless', sql: waddlerNeonHttp({ client: httpClient }), - pool: new NeonPool({ connectionString }), + pool, httpClient, - connectionString, + get connectionString() { + return readConnectionString() + }, } } - // Default ("server"): node-postgres for long-running servers + // Default ("server"): node-postgres for long-running servers. const pool = new pg.Pool({ connectionString }) return { driver: 'server', sql: waddlerNodePostgres({ client: pool }), pool, - connectionString, + get connectionString() { + return readConnectionString() + }, } }