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
32 changes: 30 additions & 2 deletions packages/database/prod/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
})
})
Expand Down Expand Up @@ -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', () => {
Expand Down
108 changes: 87 additions & 21 deletions packages/database/prod/src/main.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -10,6 +9,8 @@ import { getEnvironment } from '@netlify/runtime-utils'

import { MissingDatabaseConnectionError } from './environment.js'

type NeonHttpClient = ReturnType<typeof neon>

export interface GetDatabaseOptions {
connectionString?: string
debug?: boolean
Expand All @@ -26,11 +27,7 @@ export interface ServerlessDatabaseConnection {
driver: 'serverless'
sql: SQL
pool: NeonPool
// Using <any, any> because neon() returns NeonQueryFunction<false, false>,
// which is incompatible with drizzle-orm's NeonHttpClient (NeonQueryFunction<any, any>)
// due to contravariance in the transaction method's generic parameters.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
httpClient: NeonQueryFunction<any, any>
httpClient: NeonHttpClient
connectionString: string
}

Expand All @@ -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<NeonHttpClient>(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 })
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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()
},
}
}

Expand Down
Loading